diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index 6018722..1e0735b 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -88,18 +88,25 @@ const session = service.openDocument({ text: "SELECT * FROM ", }); -const subscription = session.onDidChange(({ reason }) => { - if (reason === "catalog" || reason === "catalog-availability") { +let completionRefreshToken: SqlCompletionRefreshToken | null = null; +const subscription = session.onDidChange(({ refreshToken }) => { + if ( + refreshToken !== null && + refreshToken === completionRefreshToken + ) { // Ask the editor adapter to request completion again. } }); -const result = await session.complete({ +const completionTask = session.complete({ position: 14, trigger: { kind: "invoked" }, }); +completionRefreshToken = completionTask.refreshToken; +const result = await completionTask; if (result.status === "ready" && session.isCurrent(result.revision)) { + completionRefreshToken = result.refreshToken; for (const item of result.value.items) { // Apply item.edit in the original document's UTF-16 coordinates. } @@ -118,10 +125,21 @@ exposing provider errors or internal epochs. The interactive catalog wait is bounded from the start of `complete()`. When the budget expires, the session returns local evidence with a -`catalog-loading` issue and a checked remaining intent lease. Compatible -readiness advances the session revision and emits `catalog-availability`; -higher provider epochs emit `catalog`. Consumers must request completion again -and apply only results whose revision remains current. +`catalog-loading` issue, a checked remaining intent lease, and an opaque +`refreshToken`. Compatible readiness advances the session revision and emits +`catalog-availability` with that exact token. A higher provider epoch emits +`catalog` with the token only while the same soft or terminal loading intent +remains leased; otherwise its token is `null`. + +Consumers compare refresh tokens by identity. A matching token is necessary, +not sufficient, to request completion again: the document, selection, context, +and adapter-owned intent must also remain unchanged. Tokens are in-process, +non-serializable control identities; they expose no provider epoch, query, or +work identity. The completion task exposes its token synchronously before +provider work starts, so consumers can latch a matching catalog event that +races the result. Ready results retain that token only while +`catalog-loading` has an unexpired lease; all other ready results use `null`. +Consumers apply only results whose revision remains current. ## Dialect registration diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index 130b5c4..631f003 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -173,6 +173,7 @@ describe("relation completion session integration", () => { trigger: { kind: "invoked" }, }); expect(result).toMatchObject({ + refreshToken: null, status: "ready", value: { items: [ @@ -312,6 +313,7 @@ describe("relation completion session integration", () => { ], }, }); + expect(result).toMatchObject({ refreshToken: null }); service.dispose(); }); @@ -410,6 +412,25 @@ describe("relation completion session integration", () => { ], }, }); + if (result.status !== "ready") { + throw new Error("Expected a ready completion"); + } + expect(result.refreshToken).not.toBeNull(); + expect(Object.isFrozen(result.refreshToken)).toBe(true); + const second = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(second).toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + if (second.status !== "ready") { + throw new Error("Expected a ready completion"); + } + expect(second.refreshToken).not.toBe(result.refreshToken); service.dispose(); }); @@ -439,9 +460,9 @@ describe("relation completion session integration", () => { }, text: "SELECT * FROM ", }); - const events: string[] = []; + const events: SqlSessionChangeEvent[] = []; session.onDidChange((event) => { - events.push(event.reason); + events.push(event); }); const result = await session.complete({ position: 14, @@ -453,6 +474,9 @@ describe("relation completion session integration", () => { issues: [{ reason: "catalog-loading" }], }, }); + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected a refreshable ready completion"); + } resolveSearch?.({ coverage: { kind: "complete" }, epoch: { generation: 0, token: "initial" }, @@ -460,7 +484,241 @@ describe("relation completion session integration", () => { status: "ready", }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(events).toEqual(["catalog-availability"]); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + reason: "catalog-availability", + }); + expect(events[0]?.refreshToken).toBe(result.refreshToken); + service.dispose(); + }); + + it("correlates terminal loading with catalog invalidation during its lease", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const result = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected a refreshable ready completion"); + } + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ reason: "catalog" }); + expect(events[0]?.refreshToken).toBe(result.refreshToken); + service.dispose(); + }); + + it("does not correlate catalog invalidation after a terminal lease expires", async () => { + vi.useFakeTimers(); + let service: + | ReturnType> + | undefined; + try { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const result = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + status: "ready", + value: { + issues: [{ remainingIntentLeaseMs: 1_000 }], + }, + }); + await vi.advanceTimersByTimeAsync(1_000); + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + } finally { + service?.dispose(); + vi.useRealTimers(); + } + }); + + it("does not correlate catalog invalidation after a soft lease expires", async () => { + vi.useFakeTimers(); + let service: + | ReturnType> + | undefined; + try { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + service = createSqlLanguageService({ + catalog: catalogProvider( + () => + new Promise(() => { + // The refresh lease owns this unsettled search. + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const task = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await vi.advanceTimersByTimeAsync(0); + const result = await task; + expect(result).toMatchObject({ + refreshToken: task.refreshToken, + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + if (result.status !== "ready") { + throw new Error("Expected a ready completion"); + } + const loading = result.value.issues.find( + (issue) => issue.reason === "catalog-loading", + ); + if (!loading || loading.reason !== "catalog-loading") { + throw new Error("Expected a loading issue"); + } + expect(loading.remainingIntentLeaseMs).toBeGreaterThan(0); + await vi.advanceTimersByTimeAsync( + Math.ceil(loading.remainingIntentLeaseMs), + ); + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + } finally { + service?.dispose(); + vi.useRealTimers(); + } + }); + + it("correlates catalog invalidation during a soft lease", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + () => + new Promise(() => { + // Catalog invalidation settles the retained intent. + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const task = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await expect(task).resolves.toMatchObject({ + refreshToken: task.refreshToken, + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toHaveLength(1); + expect(events[0]?.refreshToken).toBe(task.refreshToken); service.dispose(); }); @@ -596,9 +854,9 @@ describe("relation completion session integration", () => { }, text: "SELECT * FROM ", }); - const events: string[] = []; + const events: SqlSessionChangeEvent[] = []; session.onDidChange((event) => { - events.push(event.reason); + events.push(event); }); const result = session.complete({ position: 14, @@ -611,7 +869,12 @@ describe("relation completion session integration", () => { reason: "superseded", status: "cancelled", }); - expect(events).toEqual(["catalog"]); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: result.refreshToken, + }), + ]); service.dispose(); }); @@ -3581,20 +3844,29 @@ describe("session coverage hardening", () => { }, text, }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); await session.complete({ position: text.indexOf("a;") + 1, trigger: { kind: "invoked" }, }); - await expect( - session.complete({ - position: text.length, - trigger: { kind: "invoked" }, - }), - ).resolves.toMatchObject({ + const replacement = session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + expect(replacement).toBeInstanceOf(Promise); + expect(Object.isFrozen(replacement)).toBe(true); + await expect(replacement).resolves.toMatchObject({ reason: "superseded", status: "cancelled", }); expect(searchCount).toBe(2); + expect(events).toHaveLength(1); + expect(events[0]?.refreshToken).toBe( + replacement.refreshToken, + ); service.dispose(); }); @@ -3894,9 +4166,15 @@ describe("session coverage hardening", () => { position: 14, trigger: { kind: "invoked" }, })).resolves.toMatchObject({ + refreshToken: null, status: "ready", value: { - issues: [{ reason: "catalog-loading" }], + issues: [ + { + reason: "catalog-loading", + remainingIntentLeaseMs: 0, + }, + ], }, }); expect(synchronousCalls).toBe(1); @@ -3916,12 +4194,13 @@ describe("session coverage hardening", () => { } }); - it("does not let a stale intent callback erase a newer timer", async () => { + it("handles synchronous soft-intent timer completion", async () => { const nativeSetTimeout = globalThis.setTimeout; const nativeClearTimeout = globalThis.clearTimeout; - const callbacks: Array<() => void> = []; - const handles: object[] = []; - const cleared: number[] = []; + const cleared: unknown[] = []; + let responseTimerSeen = false; + let positiveTimersAfterResponse = 0; + let synchronousCalls = 0; Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: ( @@ -3929,52 +4208,216 @@ describe("session coverage hardening", () => { delay?: number, ...arguments_: unknown[] ) => { - if (delay !== 1_000) { - return nativeSetTimeout(callback, delay, ...arguments_); - } - if (typeof callback !== "function") { - throw new Error("Intent timer callback must be a function"); + if (delay === 0) { + responseTimerSeen = true; + } else if ( + responseTimerSeen && + typeof delay === "number" && + delay > 0 + ) { + positiveTimersAfterResponse += 1; + if (positiveTimersAfterResponse === 1) { + synchronousCalls += 1; + if (typeof callback === "function") { + Reflect.apply(callback, undefined, arguments_); + } + return null; + } } - callbacks.push(() => { - Reflect.apply(callback, undefined, arguments_); - }); - const handle = Object.freeze({ id: handles.length }); - handles.push(handle); - return handle; + return nativeSetTimeout(callback, delay, ...arguments_); }, writable: true, }); Object.defineProperty(globalThis, "clearTimeout", { configurable: true, - value: (handle: unknown) => { - const index = handles.findIndex( - (candidate) => candidate === handle, - ); - if (index >= 0) { - cleared.push(index); - } else { - Reflect.apply(nativeClearTimeout, globalThis, [handle]); - } + value: (handle: ReturnType) => { + cleared.push(handle); + nativeClearTimeout(handle); }, writable: true, }); try { - const service = catalogService({ - id: "stale-intent-timer", - search: async () => ({ - epoch: { generation: 0, token: "loading" }, - status: "loading", - }), + const service = createSqlLanguageService({ + catalog: { + id: "synchronous-soft-timer", + search: () => + new Promise(() => { + // The synthetic soft-intent timer owns settlement. + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], }); const session = service.openDocument({ context: { - catalog: { scope: "connection:stale-intent-timer" }, + catalog: { scope: "connection:synchronous-soft-timer" }, dialect: "duckdb", engine: "local", }, text: "SELECT * FROM ", }); - await session.complete({ + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + value: { + issues: [ + { + reason: "catalog-loading", + remainingIntentLeaseMs: 0, + }, + ], + }, + }); + expect(synchronousCalls).toBe(1); + expect(cleared).toContain(null); + service.dispose(); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: nativeClearTimeout, + writable: true, + }); + } + }); + + it("ignores a cleared soft-intent timer callback", async () => { + const nativeSetTimeout = globalThis.setTimeout; + let responseTimerSeen = false; + let staleCallback: (() => void) | undefined; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => { + if (delay === 0) { + responseTimerSeen = true; + } else if ( + responseTimerSeen && + staleCallback === undefined && + typeof callback === "function" + ) { + staleCallback = () => { + Reflect.apply(callback, undefined, arguments_); + }; + } + return nativeSetTimeout(callback, delay, ...arguments_); + }, + writable: true, + }); + try { + const service = createSqlLanguageService({ + catalog: { + id: "stale-soft-timer", + search: () => + new Promise(() => { + // Session lifecycle owns this unsettled search. + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:stale-soft-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(staleCallback).toBeTypeOf("function"); + const replacement = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + staleCallback?.(); + session.dispose(); + await expect(replacement).resolves.toMatchObject({ + reason: "disposed", + status: "cancelled", + }); + service.dispose(); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + } + }); + + it("does not let a stale intent callback erase a newer timer", async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const callbacks: Array<() => void> = []; + const handles: object[] = []; + const cleared: number[] = []; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => { + if (delay !== 1_000) { + return nativeSetTimeout(callback, delay, ...arguments_); + } + if (typeof callback !== "function") { + throw new Error("Intent timer callback must be a function"); + } + callbacks.push(() => { + Reflect.apply(callback, undefined, arguments_); + }); + const handle = Object.freeze({ id: handles.length }); + handles.push(handle); + return handle; + }, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (handle: unknown) => { + const index = handles.findIndex( + (candidate) => candidate === handle, + ); + if (index >= 0) { + cleared.push(index); + } else { + Reflect.apply(nativeClearTimeout, globalThis, [handle]); + } + }, + writable: true, + }); + try { + const service = catalogService({ + id: "stale-intent-timer", + search: async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:stale-intent-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ position: 14, trigger: { kind: "invoked" }, }); @@ -4217,8 +4660,8 @@ describe("session coverage hardening", () => { trigger: { kind: "invoked" }, }), ).resolves.toMatchObject({ - reason: "inactive", - status: "unavailable", + reason: "superseded", + status: "cancelled", }); service.dispose(); await expect(nested).resolves.toMatchObject({ @@ -4227,6 +4670,476 @@ describe("session coverage hardening", () => { }); }); + it("publishes the task token before reentrant signal invalidation", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: { + id: "signal-invalidation", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:signal-invalidation" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + let task: + | ReturnType + | undefined; + let matchedPublishedToken = false; + session.onDidChange((event) => { + matchedPublishedToken = + task !== undefined && + event.refreshToken === task.refreshToken; + }); + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + invalidate?.({ + epoch: { generation: 1, token: "signal" }, + }); + nativeAdd(type, listener, options); + }, + }); + task = session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + }); + await expect(task).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + expect(matchedPublishedToken).toBe(true); + service.dispose(); + }); + + it("handles abort reentrancy during signal registration", async () => { + const service = catalogService({ + id: "signal-abort", + search: async () => + new Promise(() => { + // Abort occurs before provider invocation. + }), + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:signal-abort" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + nativeAdd(type, listener, options); + controller.abort(); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + service.dispose(); + }); + + it("rechecks abort after signal registration", async () => { + let searchCount = 0; + const service = catalogService({ + id: "signal-abort-before-registration", + search: async () => { + searchCount += 1; + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + }); + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:signal-abort-before-registration", + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + controller.abort(); + nativeAdd(type, listener, options); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + expect(searchCount).toBe(0); + service.dispose(); + }); + + it.each(["before", "after"] as const)( + "preserves a nested completion when the signal becomes aborted %s registration", + async (phase) => { + let searchCount = 0; + const service = catalogService({ + id: `signal-aborted-getter-${phase}`, + search: async () => { + searchCount += 1; + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + }); + const session = service.openDocument({ + context: { + catalog: { + scope: `connection:signal-aborted-getter-${phase}`, + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + let reads = 0; + let nested: + | ReturnType + | undefined; + Object.defineProperty(controller.signal, "aborted", { + configurable: true, + get: () => { + reads += 1; + if ( + !nested && + (phase === "before" || reads === 2) + ) { + nested = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + return true; + } + return false; + }, + }); + + const outer = session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + }); + await expect(outer).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + await expect(nested).resolves.toMatchObject({ + status: "ready", + }); + expect(searchCount).toBe(1); + service.dispose(); + }, + ); + + it("cleans up when signal registration throws", async () => { + let generation = 0; + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + id: "throwing-signal-add", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { + generation, + token: `generation:${generation}`, + }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:throwing-signal-add" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const controller = new AbortController(); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: () => { + throw new Error("registration failed"); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).rejects.toThrow("registration failed"); + generation = 1; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + const reentrantController = new AbortController(); + Object.defineProperty( + reentrantController.signal, + "addEventListener", + { + configurable: true, + value: () => { + generation = 2; + invalidate?.({ + epoch: { + generation, + token: `generation:${generation}`, + }, + }); + throw new Error("reentrant registration failed"); + }, + }, + ); + const reentrantTask = session.complete({ + position: 14, + signal: reentrantController.signal, + trigger: { kind: "invoked" }, + }); + await expect(reentrantTask).rejects.toThrow( + "reentrant registration failed", + ); + expect(events[1]?.refreshToken).toBe( + reentrantTask.refreshToken, + ); + generation = 3; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events[2]).toMatchObject({ + reason: "catalog", + refreshToken: null, + }); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + service.dispose(); + }); + + it("cleans up when the signal aborted getter throws", async () => { + let generation = 0; + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + id: "throwing-signal-aborted", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { + generation, + token: `generation:${generation}`, + }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:throwing-signal-aborted", + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const controller = new AbortController(); + Object.defineProperty(controller.signal, "aborted", { + configurable: true, + get: () => { + throw new Error("aborted read failed"); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).rejects.toThrow("aborted read failed"); + generation = 1; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + service.dispose(); + }); + + it("contains signal cleanup failures after making the task inert", async () => { + let generation = 0; + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + id: "throwing-signal-remove", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { + generation, + token: `generation:${generation}`, + }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:throwing-signal-remove" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const controller = new AbortController(); + Object.defineProperty(controller.signal, "removeEventListener", { + configurable: true, + value: () => { + throw new Error("cleanup failed"); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + generation = 1; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + service.dispose(); + }); + it("stops catalog event delivery when a listener disposes the session", () => { let invalidate: | ((event: SqlCatalogInvalidation) => void) diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 8204d7b..7628924 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -42,12 +42,14 @@ export type { SqlCompletionCancellationReason, SqlCompletionIssue, SqlCompletionRequest, + SqlCompletionRefreshToken, SqlCompletionTrigger, SqlDisposable, SqlRelationCatalogProvider, SqlCompletionItem, SqlCompletionList, SqlCompletionResult, + SqlCompletionTask, SqlSessionChangeEvent, SqlSessionChangeReason, } from "./relation-completion-types.js"; diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 56c8952..ce950fd 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -187,11 +187,41 @@ export type SqlSessionChangeReason = | "catalog-availability" | "provider-configuration"; -export interface SqlSessionChangeEvent { - readonly revision: SqlRevision; - readonly reason: SqlSessionChangeReason; +const completionRefreshTokenBrand: unique symbol = Symbol( + "SqlCompletionRefreshToken", +); + +/** Opaque, in-process identity for one completion refresh intent. */ +export interface SqlCompletionRefreshToken { + readonly [completionRefreshTokenBrand]: "SqlCompletionRefreshToken"; +} + +/** @internal */ +export function createSqlCompletionRefreshToken(): SqlCompletionRefreshToken { + const token: SqlCompletionRefreshToken = { + [completionRefreshTokenBrand]: "SqlCompletionRefreshToken", + }; + Object.freeze(token); + return token; } +export type SqlSessionChangeEvent = + | { + readonly revision: SqlRevision; + readonly reason: "catalog-availability"; + readonly refreshToken: SqlCompletionRefreshToken; + } + | { + readonly revision: SqlRevision; + readonly reason: "catalog"; + readonly refreshToken: SqlCompletionRefreshToken | null; + } + | { + readonly revision: SqlRevision; + readonly reason: "provider-configuration"; + readonly refreshToken: null; + }; + export type SqlCompletionTrigger = | { readonly character?: never; @@ -324,6 +354,7 @@ export type SqlCompletionResult = | { readonly status: "ready"; readonly revision: SqlRevision; + readonly refreshToken: SqlCompletionRefreshToken | null; readonly value: SqlCompletionList; readonly sources: readonly SqlCatalogProviderReport[]; } @@ -343,3 +374,12 @@ export type SqlCompletionResult = readonly revision: SqlRevision; readonly failure: SqlServiceFailure; }; + +/** + * A completion invocation whose identity is available before provider work + * starts. + */ +export interface SqlCompletionTask + extends Promise { + readonly refreshToken: SqlCompletionRefreshToken; +} diff --git a/src/vnext/session.ts b/src/vnext/session.ts index 80b1169..fa41d79 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -43,12 +43,14 @@ import { MAX_RELATION_COMPLETION_RESULTS, type SqlComposableCatalogOutcome, } from "./relation-completion.js"; -import type { - SqlCompletionRequest, - SqlDisposable, - SqlCompletionResult, - SqlSessionChangeEvent, - SqlSessionChangeReason, +import { + createSqlCompletionRefreshToken, + type SqlCompletionRequest, + type SqlCompletionRefreshToken, + type SqlDisposable, + type SqlCompletionResult, + type SqlCompletionTask, + type SqlSessionChangeEvent, } from "./relation-completion-types.js"; import { BIGQUERY_SQL_RELATION_DIALECT, @@ -93,9 +95,18 @@ interface CompletionRequestState { cancelReason: "caller" | "disposed" | "superseded" | null; readonly revision: SqlRevision; ticket: SqlCatalogSearchWorkTicket | null; - readonly token: object; + readonly token: SqlCompletionRefreshToken; } +type ServiceChange = + | { + readonly reason: "catalog-availability"; + readonly expected: CompletionRequestState; + } + | { + readonly reason: "catalog"; + }; + interface SessionChangeSubscription { active: boolean; readonly listener: (event: SqlSessionChangeEvent) => void; @@ -106,6 +117,11 @@ interface SessionTimerCell { handle: ReturnType | undefined; } +interface TerminalRefreshIntent { + readonly timer: SessionTimerCell; + readonly token: SqlCompletionRefreshToken; +} + interface CompletionConfiguration { readonly catalogResponseBudgetMs: number; } @@ -499,6 +515,12 @@ function completionCancellation( return Object.freeze({ reason, revision, status: "cancelled" }); } +function completionCancellationReason( + request: CompletionRequestState, +): "caller" | "disposed" | "superseded" { + return request.cancelReason ?? "superseded"; +} + interface MissingDataProperty { readonly found: false; } @@ -743,6 +765,13 @@ interface LocalRelationStatementCache { readonly sourceSequence: number; } +function createCompletionTask( + refreshToken: SqlCompletionRefreshToken, + result: Promise, +): SqlCompletionTask { + return Object.freeze(Object.assign(result, { refreshToken })); +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { @@ -761,8 +790,9 @@ export class DefaultSqlDocumentSession | null = null; #refreshIntent: CompletionRequestState | null = null; #snapshot: SessionSnapshot; + #softRefreshIntentTimer: SessionTimerCell | null = null; #statementIndexCache: StatementIndexCache | null = null; - #terminalIntentTimer: SessionTimerCell | null = null; + #terminalRefreshIntent: TerminalRefreshIntent | null = null; #updating = false; constructor( @@ -836,25 +866,32 @@ export class DefaultSqlDocumentSession } #clearTerminalIntent(): void { - const cell = this.#terminalIntentTimer; - if (!cell) return; - this.#terminalIntentTimer = null; - cell.active = false; - clearTimeout(cell.handle); + const intent = this.#terminalRefreshIntent; + if (!intent) return; + this.#terminalRefreshIntent = null; + intent.timer.active = false; + clearTimeout(intent.timer.handle); + } + + #clearSoftRefreshIntentTimer(): void { + const timer = this.#softRefreshIntentTimer; + if (!timer) return; + this.#softRefreshIntentTimer = null; + timer.active = false; + clearTimeout(timer.handle); } - #dispatchChange( - revision: SqlRevision, - reason: SqlSessionChangeReason, - ): void { - if (this.#disposed || revision !== this.#snapshot.revision) { + #dispatchChange(event: SqlSessionChangeEvent): void { + if ( + this.#disposed || + event.revision !== this.#snapshot.revision + ) { return; } - const event = Object.freeze({ reason, revision }); for (const subscription of Array.from(this.#listeners)) { if ( this.#disposed || - revision !== this.#snapshot.revision + event.revision !== this.#snapshot.revision ) { return; } @@ -873,9 +910,12 @@ export class DefaultSqlDocumentSession } #prepareServiceChange( - reason: SqlSessionChangeReason, - expected?: CompletionRequestState, + change: ServiceChange, ): (() => undefined) | null { + const expected = + change.reason === "catalog-availability" + ? change.expected + : undefined; if ( this.#disposed || (expected !== undefined && @@ -886,6 +926,19 @@ export class DefaultSqlDocumentSession ) { return null; } + const terminal = this.#terminalRefreshIntent; + const soft = this.#refreshIntent; + const activeIntent = this.#activeCompletion; + const refreshToken = + change.reason === "catalog-availability" + ? change.expected.token + : soft?.cancelReason === null + ? soft.token + : terminal?.timer.active === true + ? terminal.token + : activeIntent?.cancelReason === null + ? activeIntent.token + : null; const previous = this.#snapshot; const revision = createSqlRevisionToken(); this.#snapshot = Object.freeze({ @@ -893,10 +946,23 @@ export class DefaultSqlDocumentSession revision, sequence: previous.sequence + 1, }); + const event: SqlSessionChangeEvent = + change.reason === "catalog-availability" + ? Object.freeze({ + reason: change.reason, + refreshToken: change.expected.token, + revision, + }) + : Object.freeze({ + reason: change.reason, + refreshToken, + revision, + }); const active = this.#activeCompletion; const intent = this.#refreshIntent; this.#activeCompletion = null; this.#refreshIntent = null; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); if (active && active.cancelReason === null) { active.cancelReason = "superseded"; @@ -909,7 +975,7 @@ export class DefaultSqlDocumentSession return (): undefined => { active?.ticket?.cancel(); if (intent !== active) intent?.ticket?.cancel(); - this.#dispatchChange(revision, reason); + this.#dispatchChange(event); return undefined; }; } @@ -937,7 +1003,7 @@ export class DefaultSqlDocumentSession dialect, Object.freeze({ prepareCatalogChange: (): (() => undefined) | null => - this.#prepareServiceChange("catalog"), + this.#prepareServiceChange({ reason: "catalog" }), }), ); if (prepared.status !== "prepared") return; @@ -984,9 +1050,18 @@ export class DefaultSqlDocumentSession }); }; - readonly complete = async ( + readonly complete = ( request: SqlCompletionRequest, - ): Promise => { + ): SqlCompletionTask => { + const refreshToken = createSqlCompletionRefreshToken(); + const result = this.#runCompletion(request, refreshToken); + return createCompletionTask(refreshToken, result); + }; + + async #runCompletion( + request: SqlCompletionRequest, + refreshToken: SqlCompletionRefreshToken, + ): Promise { const completionStartedAt = performance.now(); if (this.#disposed) { throw new SqlSessionError( @@ -1107,22 +1182,36 @@ export class DefaultSqlDocumentSession if (this.#refreshIntent === previousIntent) { this.#refreshIntent = null; } + this.#clearSoftRefreshIntentTimer(); }; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); const snapshot = this.#snapshot; const active: CompletionRequestState = { cancelReason: null, revision: snapshot.revision, ticket: null, - token: {}, + token: refreshToken, }; this.#activeCompletion = active; - if (signal?.aborted) { - active.cancelReason = "caller"; - this.#activeCompletion = null; - cancelPrevious(); - return completionCancellation(snapshot.revision, "caller"); - } + const cancellationIfNotCurrent = + (): SqlCompletionResult | null => { + if ( + this.#activeCompletion === active && + active.cancelReason === null && + snapshot.revision === this.#snapshot.revision + ) { + return null; + } + cancelPrevious(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + }; + await Promise.resolve(); + const publicationCancellation = cancellationIfNotCurrent(); + if (publicationCancellation) return publicationCancellation; const onAbort = (): void => { if ( this.#activeCompletion === active && @@ -1132,9 +1221,66 @@ export class DefaultSqlDocumentSession active.ticket?.cancel(); } }; - signal?.addEventListener("abort", onAbort, { once: true }); - + const makeInvocationInert = (): void => { + if (this.#activeCompletion === active) { + this.#activeCompletion = null; + } + if (active.cancelReason === null) { + active.cancelReason = "superseded"; + } + cancelPrevious(); + }; + const readSignalAborted = ( + currentSignal: AbortSignal, + ): boolean => { + try { + return currentSignal.aborted; + } catch (error) { + makeInvocationInert(); + throw error; + } + }; + let signalRegistrationAttempted = false; try { + if (signal) { + const abortedBeforeRegistration = + readSignalAborted(signal); + const readCancellation = cancellationIfNotCurrent(); + if (readCancellation) return readCancellation; + if (abortedBeforeRegistration) { + active.cancelReason = "caller"; + this.#activeCompletion = null; + cancelPrevious(); + return completionCancellation( + snapshot.revision, + "caller", + ); + } + signalRegistrationAttempted = true; + try { + signal.addEventListener("abort", onAbort, { once: true }); + } catch (error) { + makeInvocationInert(); + throw error; + } + const registrationCancellation = + cancellationIfNotCurrent(); + if (registrationCancellation) { + return registrationCancellation; + } + const abortedAfterRegistration = + readSignalAborted(signal); + const rereadCancellation = cancellationIfNotCurrent(); + if (rereadCancellation) return rereadCancellation; + if (abortedAfterRegistration) { + onAbort(); + cancelPrevious(); + return completionCancellation( + snapshot.revision, + "caller", + ); + } + } const index = this.getStatementIndexForTesting(); const slot = findSqlStatementSlot(index, position, "left"); const cachedLocal = this.#localRelationStatementCache; @@ -1189,7 +1335,7 @@ export class DefaultSqlDocumentSession ) { return completionCancellation( snapshot.revision, - active.cancelReason ?? "superseded", + completionCancellationReason(active), ); } @@ -1213,6 +1359,17 @@ export class DefaultSqlDocumentSession status: "unavailable", }); } else { + if ( + this.#activeCompletion !== active || + active.cancelReason !== null || + snapshot.revision !== this.#snapshot.revision + ) { + cancelPrevious(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } const ticket = owner.request({ continuationToken: null, limit: MAX_RELATION_COMPLETION_RESULTS, @@ -1258,7 +1415,7 @@ export class DefaultSqlDocumentSession ticket.cancel(); return completionCancellation( snapshot.revision, - active.cancelReason ?? "superseded", + completionCancellationReason(active), ); } let providerOutcome: SqlCatalogSearchWorkOutcome | null = @@ -1267,13 +1424,42 @@ export class DefaultSqlDocumentSession const retained = ticket.retainForRefresh( (): (() => undefined) | null => this.#prepareServiceChange( - "catalog-availability", - active, + { + expected: active, + reason: "catalog-availability", + }, ), ); if (retained.status === "retained") { remainingIntentLeaseMs = retained.remainingLeaseMs; this.#refreshIntent = active; + const cell: SessionTimerCell = { + active: true, + handle: undefined, + }; + this.#softRefreshIntentTimer = cell; + const handle = setTimeout(() => { + if ( + !cell.active || + this.#softRefreshIntentTimer !== cell + ) { + return; + } + cell.active = false; + this.#softRefreshIntentTimer = null; + this.#refreshIntent = null; + ticket.cancel(); + }, remainingIntentLeaseMs); + cell.handle = handle; + if ( + !cell.active || + this.#softRefreshIntentTimer !== cell + ) { + clearTimeout(handle); + } + if (this.#refreshIntent !== active) { + remainingIntentLeaseMs = 0; + } catalogOutcome = Object.freeze({ status: "loading", }); @@ -1339,21 +1525,28 @@ export class DefaultSqlDocumentSession active: true, handle: undefined, }; - this.#terminalIntentTimer = cell; + const intent: TerminalRefreshIntent = { + timer: cell, + token: active.token, + }; + this.#terminalRefreshIntent = intent; const handle = setTimeout(() => { if (!cell.active) return; cell.active = false; - if (this.#terminalIntentTimer === cell) { - this.#terminalIntentTimer = null; + if (this.#terminalRefreshIntent === intent) { + this.#terminalRefreshIntent = null; } }, remainingIntentLeaseMs); cell.handle = handle; if ( !cell.active || - this.#terminalIntentTimer !== cell + this.#terminalRefreshIntent !== intent ) { clearTimeout(handle); } + if (this.#terminalRefreshIntent !== intent) { + remainingIntentLeaseMs = 0; + } } } } @@ -1381,22 +1574,36 @@ export class DefaultSqlDocumentSession ) { return completionCancellation( snapshot.revision, - active.cancelReason ?? "superseded", + completionCancellationReason(active), ); } return Object.freeze({ + refreshToken: + (catalogOutcome?.status === "loading" || + (catalogOutcome?.status === "usable" && + catalogOutcome.response.status === "loading")) && + (this.#refreshIntent === active || + this.#terminalRefreshIntent?.token === active.token) + ? active.token + : null, revision: snapshot.revision, sources: composition.sources, status: "ready", value: composition.value, }); } finally { - signal?.removeEventListener("abort", onAbort); if (this.#activeCompletion === active) { this.#activeCompletion = null; } + if (signal && signalRegistrationAttempted) { + try { + signal.removeEventListener("abort", onAbort); + } catch { + // Signal cleanup cannot retain or fail the completion. + } + } } - }; + } readonly update = (update: SqlDocumentUpdate): SqlRevision => { if (this.#disposed) { @@ -1675,6 +1882,7 @@ export class DefaultSqlDocumentSession } this.#activeCompletion = null; this.#refreshIntent = null; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); if ( activeCompletion && @@ -1712,6 +1920,7 @@ export class DefaultSqlDocumentSession this.#activeCompletion = null; this.#refreshIntent = null; this.#catalogOwner = null; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); this.#listeners.clear(); this.#localRelationStatementCache = null; diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 18d11e8..1931e7e 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -138,7 +138,7 @@ export interface SqlDocumentSession { readonly update: (update: SqlDocumentUpdate) => SqlRevision; readonly complete: ( request: SqlCompletionRequest, - ) => Promise; + ) => SqlCompletionTask; readonly onDidChange: ( listener: (event: SqlSessionChangeEvent) => void, ) => SqlDisposable; @@ -187,8 +187,8 @@ export class SqlSessionError extends Error { } import type { SqlCompletionRequest, + SqlCompletionTask, SqlDisposable, SqlRelationCatalogProvider, - SqlCompletionResult, SqlSessionChangeEvent, } from "./relation-completion-types.js"; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 9a8e74d..30411a3 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -6,6 +6,8 @@ import type { SqlDocumentUpdate, SqlEmbeddedRegion, SqlIdentifierComponent, + SqlCompletionRefreshToken, + SqlCompletionTask, OpenSqlDocument, } from "../../src/vnext/index.js"; import type { @@ -22,6 +24,7 @@ import type { SqlCompletionItem, SqlCompletionList, SqlRelationCatalogProvider, + SqlSessionChangeEvent, } from "../../src/vnext/relation-completion-types.js"; import type { SqlCatalogEpochTransitionTarget, @@ -96,25 +99,74 @@ const subscription = session.onDidChange((event) => { const reason: "catalog" | "catalog-availability" | "provider-configuration" = event.reason; session.isCurrent(event.revision); + if (event.reason === "catalog-availability") { + const token: SqlCompletionRefreshToken = event.refreshToken; + void token; + } void reason; }); subscription.dispose(); subscription.dispose(); -void session.complete({ +const completionTask: SqlCompletionTask = session.complete({ position: 14, signal: new AbortController().signal, trigger: { kind: "invoked" }, }); +const invocationToken: SqlCompletionRefreshToken = + completionTask.refreshToken; +void invocationToken; void session.complete({ position: 14, trigger: { character: ".", kind: "trigger-character" }, }).then((result) => { + if (result.status === "ready") { + const token: SqlCompletionRefreshToken | null = + result.refreshToken; + void token; + } // @ts-expect-error scheduler work identities never enter consumer results void result.workId; // @ts-expect-error catalog epochs never enter consumer results void result.epoch; }); +declare const refreshToken: SqlCompletionRefreshToken; +const catalogAvailabilityEvent = { + reason: "catalog-availability", + refreshToken, + revision: session.revision, +} satisfies SqlSessionChangeEvent; +const catalogEventWithoutIntent = { + reason: "catalog", + refreshToken: null, + revision: session.revision, +} satisfies SqlSessionChangeEvent; +const providerConfigurationEvent = { + reason: "provider-configuration", + refreshToken: null, + revision: session.revision, +} satisfies SqlSessionChangeEvent; +const invalidAvailabilityEvent = { + reason: "catalog-availability", + refreshToken: null, + revision: session.revision, + // @ts-expect-error availability always identifies the exact refresh intent +} satisfies SqlSessionChangeEvent; +const invalidProviderConfigurationEvent = { + reason: "provider-configuration", + refreshToken, + revision: session.revision, + // @ts-expect-error provider configuration is never a completion refresh +} satisfies SqlSessionChangeEvent; +// @ts-expect-error completion refresh identities are service-issued +const fabricatedRefreshToken: SqlCompletionRefreshToken = {}; +void catalogAvailabilityEvent; +void catalogEventWithoutIntent; +void providerConfigurationEvent; +void invalidAvailabilityEvent; +void invalidProviderConfigurationEvent; +void fabricatedRefreshToken; + const provider: SqlRelationCatalogProvider = { id: "marimo", search: async (request, signal) => {