Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(cloud,local-runner): refuse ambiguous project match in schedule -…
…-remove
  • Loading branch information
mfranczel committed Sep 30, 2026
commit efcbf1b293a6bccabfa6701e5c5df2842f21a8ce
27 changes: 27 additions & 0 deletions packages/cloud/src/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,33 @@ describe('findNotebook', () => {
expect(await findNotebook(BASE_URL, TOKEN, { projectName: 'Nope' })).toBeUndefined()
})

it('refuses to pick between several matching projects when a unique match is required', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
projectsPage([
{ id: 'p-old', name: 'P', createdAt: '2026-01-01', notebooks: [{ id: 'nb-old', name: 'Main' }] },
{ id: 'p-new', name: 'P', createdAt: '2026-02-01', notebooks: [{ id: 'nb-new', name: 'Main' }] },
])
)

await expect(
findNotebook(BASE_URL, TOKEN, { projectName: 'P', notebookName: 'Main', unique: true })
).rejects.toThrow('2 Deepnote projects named "P" match this notebook.')
})

it('ignores same-named projects without the notebook when a unique match is required', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
projectsPage([
{ id: 'p-other', name: 'P', createdAt: '2026-02-01', notebooks: [{ id: 'nb-x', name: 'Other' }] },
{ id: 'p-wanted', name: 'P', createdAt: '2026-01-01', notebooks: [{ id: 'nb-y', name: 'Main' }] },
])
)

expect(await findNotebook(BASE_URL, TOKEN, { projectName: 'P', notebookName: 'Main', unique: true })).toEqual({
notebookId: 'nb-y',
projectId: 'p-wanted',
})
})

it('throws rather than reporting absence when the page walk runs out of pages', async () => {
// A workspace still offering pages is a lookup that has not finished. Answering "not here" from
// it is how `createIfMissing` ends up creating a project that already exists.
Expand Down
13 changes: 11 additions & 2 deletions packages/cloud/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface FindNotebookQuery {
projectName: string
/** The notebook name to match within the project. Omit to take the project's first notebook. */
notebookName?: string
/** Throw instead of taking the newest project when several match. */
unique?: boolean
}

export interface FoundNotebook {
Expand Down Expand Up @@ -189,6 +191,7 @@ export async function findNotebook(
): Promise<FoundNotebook | undefined> {
const projects = await findProjectsByExactName(baseUrl, token, query.projectName, options)

const matches: FoundNotebook[] = []
for (const project of projects) {
const notebooks = project.notebooks ?? []
// Notebook names are unique within a Deepnote project, so a different notebook is never a
Expand All @@ -199,10 +202,16 @@ export async function findNotebook(
? notebooks.find(candidate => candidate.name === query.notebookName)
: notebooks[0]
if (notebook) {
return { notebookId: notebook.id, projectId: project.id }
matches.push({ notebookId: notebook.id, projectId: project.id })
}
}
return undefined
if (query.unique && matches.length > 1) {
throw new Error(
`${matches.length} Deepnote projects named "${query.projectName}" match this notebook. ` +
'Rename or delete the duplicates so it can be identified.'
)
}
return matches[0]
}

export interface Workspace {
Expand Down
5 changes: 3 additions & 2 deletions packages/local-runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,9 @@ const result = await unscheduleInCloud("examples/6_with_inputs.deepnote", {
// result.removed / result.notebookId
```

It finds the notebook the same way as `scheduleInCloud` but never creates anything. `removed` is
`false` when there was no schedule, and `notebookId` is `null` when the notebook is not in Deepnote.
It finds the notebook like `scheduleInCloud`, but never creates anything and won't guess between
same-named projects. `removed` is `false` when there was no schedule, and `notebookId` is `null`
when the notebook is not in Deepnote.

### Serve it to a static page

Expand Down
4 changes: 2 additions & 2 deletions packages/local-runner/src/schedule-in-cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ describe('unscheduleInCloud', () => {
expect(cloudMock.findNotebook).toHaveBeenCalledWith(
'https://api.deepnote.com',
'token',
{ projectName: 'Scheduled report', notebookName: 'Daily report' },
{ projectName: 'Scheduled report', notebookName: 'Daily report', unique: true },
{ requestTimeoutMs: 1_000 }
)
expect(cloudMock.deleteNotebookSchedule).toHaveBeenCalledOnce()
Expand Down Expand Up @@ -403,7 +403,7 @@ describe('unscheduleInCloud', () => {
expect(cloudMock.findNotebook).toHaveBeenCalledWith(
'https://api.deepnote.com',
'token',
{ projectName: 'Scheduled report', notebookName: 'Other report' },
{ projectName: 'Scheduled report', notebookName: 'Other report', unique: true },
expect.anything()
)
expect(cloudMock.deleteNotebookSchedule).toHaveBeenCalledWith(
Expand Down
8 changes: 4 additions & 4 deletions packages/local-runner/src/schedule-in-cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ export async function unscheduleInCloud(
}

/**
* Find the notebook the way {@link scheduleInCloud} does: by local id, then by project and notebook
* name, since notebooks created from the file get new ids. The id is checked with a read because a
* schedule 404 can mean either "no notebook" or "no schedule".
* Find the file's notebook: by local id, then by project and notebook name, since notebooks created
* from the file get new ids. The id is checked with a read because a schedule 404 can mean either
* "no notebook" or "no schedule".
*/
async function findCloudNotebookId({
baseUrl,
Expand All @@ -210,7 +210,7 @@ async function findCloudNotebookId({
const found = await findNotebook(
baseUrl,
token,
{ projectName: file.project.name, notebookName: notebookNameFor(file, localId) },
{ projectName: file.project.name, notebookName: notebookNameFor(file, localId), unique: true },
requestOptions
)
return found?.notebookId ?? null
Expand Down
3 changes: 2 additions & 1 deletion skills/deepnote/references/cli-schedule.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ deepnote schedule project.deepnote --notebook "Weekly review" --remove

`--remove` deletes only the selected notebook's schedule. It never creates anything, works on any
plan, and cannot be combined with a frequency, `--at`, `--timezone`, or `--open`. If there is
nothing to remove, it says so and exits `0`.
nothing to remove, it says so and exits `0`. If several projects share the name and notebook, it
fails instead of guessing.

## Output and Exit Codes

Expand Down