diff --git a/Source/Drizzle/PostgreSQLObservationManager.ts b/Source/Drizzle/PostgreSQLObservationManager.ts index b2b96fea..4032ef7a 100644 --- a/Source/Drizzle/PostgreSQLObservationManager.ts +++ b/Source/Drizzle/PostgreSQLObservationManager.ts @@ -44,83 +44,119 @@ export class PostgreSQLObservationManager { acquire(tenant: string, database: DrizzleDatabase, table: Table, changed: (catchup?: boolean) => void, fail: (error: Error) => void, complete?: () => void): DrizzleObservationLease { if (this.#disposed) throw new Error('PostgreSQL change listener has been disposed'); - let entry = this.#entries.get(tenant); - if (!entry) { - const gate = readiness(); - entry = { tenant, controller: new AbortController(), leases: new Set(), identities: new Map(), - routing: new Map(), startup: Promise.resolve(), dead: false, generation: 0, available: false, - readiness: gate.promise, resolveReady: gate.resolve, rejectReady: gate.reject, retries: 0, wasReady: false }; - this.#entries.set(tenant, entry); - entry.startup = this.start(entry).catch(error => { - const failure = new Error(`PostgreSQL change listener could not start for tenant '${tenant}': acquisition`, { cause: error }); - this.terminate(entry!, failure); - throw failure; - }); - void entry.startup.catch(() => {}); - } - const owner = entry; + const owner = this.#entries.get(tenant) ?? this.createEntry(tenant); const lease: Lease = { table, database, changed, fail, complete, released: false }; owner.leases.add(lease); - const release = (): void => { - if (lease.released) return; - lease.released = true; - owner.leases.delete(lease); - if (lease.key) { - const routed = owner.routing.get(lease.key); - routed?.delete(lease); - if (!routed?.size) owner.routing.delete(lease.key); - } - if (![...owner.leases].some(other => other.table === table)) owner.identities.delete(table); - if (!owner.leases.size) this.shutdown(owner); - }; - const ready = (async () => { - try { - await owner.startup; - while (true) { - if (lease.released || owner.dead) throw new Error('Drizzle observation was closed'); - if (owner.wasReady && !owner.available) await owner.readiness; - if (lease.released || owner.dead) throw new Error('Drizzle observation was closed'); - const generation = owner.generation; - try { - const identity = await this.bounded(owner, () => resolvePostgreSQLTable(database, table, tenant), undefined, false); - if (lease.released || owner.dead) throw new Error('Drizzle observation was closed'); - // A loss during a reader operation invalidates the listener used for the rest of validation. - if (owner.wasReady && (generation !== owner.generation || !owner.available)) continue; - const listenerDatabase = await this.bounded(owner, () => owner.connection!.query('SELECT current_database() AS database') - .catch(error => { throw new Error(`PostgreSQL observation could not verify the listener database for tenant '${tenant}'`, { cause: error }); })); - if (listenerDatabase.rows[0]?.database !== identity.database) - throw new Error(`PostgreSQL observation database/table mapping differs between reader and listener for tenant '${tenant}'`); - await this.validate(owner, identity); - if (lease.released || owner.dead) throw new Error('Drizzle observation was closed'); - if (owner.wasReady && (generation !== owner.generation || !owner.available)) continue; - const existing = owner.identities.get(table); - if (existing && (existing.key !== identity.key || existing.database !== identity.database)) - throw new Error(`PostgreSQL observation database/table mapping differs between reader and listener for tenant '${tenant}'`); - owner.identities.set(table, identity); - lease.key = identity.key; - let routed = owner.routing.get(identity.key); - if (!routed) { routed = new Set(); owner.routing.set(identity.key, routed); } - routed.add(lease); - if (!owner.wasReady) { - owner.available = true; - owner.wasReady = true; - owner.resolveReady(owner.generation); - this.heartbeat(owner); - } - break; - } catch (error) { - if (owner.wasReady && !lease.released && !owner.dead && - (generation !== owner.generation || !owner.available)) continue; - throw error; - } - } - } catch (error) { release(); throw error; } - })(); + const release = (): void => this.release(owner, lease); + const ready = this.prepareLease(owner, lease); void ready.catch(() => {}); return { ready, release, whenReady: () => owner.dead || lease.released ? Promise.reject(new Error('Drizzle observation was closed')) : owner.readiness, isCurrent: generation => !owner.dead && owner.available && owner.generation === generation }; } + private createEntry(tenant: string): Entry { + const gate = readiness(); + const entry: Entry = { tenant, controller: new AbortController(), leases: new Set(), identities: new Map(), + routing: new Map(), startup: Promise.resolve(), dead: false, generation: 0, available: false, + readiness: gate.promise, resolveReady: gate.resolve, rejectReady: gate.reject, retries: 0, wasReady: false }; + this.#entries.set(tenant, entry); + entry.startup = this.start(entry).catch(error => { + const failure = new Error(`PostgreSQL change listener could not start for tenant '${tenant}': acquisition`, { cause: error }); + this.terminate(entry, failure); + throw failure; + }); + void entry.startup.catch(() => {}); + return entry; + } + + private release(owner: Entry, lease: Lease): void { + if (lease.released) return; + lease.released = true; + owner.leases.delete(lease); + if (lease.key) { + const routed = owner.routing.get(lease.key); + routed?.delete(lease); + if (!routed?.size) owner.routing.delete(lease.key); + } + if (![...owner.leases].some(other => other.table === lease.table)) owner.identities.delete(lease.table); + if (!owner.leases.size) this.shutdown(owner); + } + + private async prepareLease(owner: Entry, lease: Lease): Promise { + try { + await owner.startup; + while (true) { + this.requireLiveLease(owner, lease); + if (owner.wasReady && !owner.available) await owner.readiness; + this.requireLiveLease(owner, lease); + const generation = owner.generation; + try { + if (await this.prepareLeaseAttempt(owner, lease, generation)) break; + } catch (error) { + if (this.canRetryLease(owner, lease, generation)) continue; + throw error; + } + } + } catch (error) { this.release(owner, lease); throw error; } + } + + private async prepareLeaseAttempt(owner: Entry, lease: Lease, generation: number): Promise { + const identity = await this.bounded(owner, () => resolvePostgreSQLTable(lease.database, lease.table, owner.tenant), undefined, false); + this.requireLiveLease(owner, lease); + // A loss during a reader operation invalidates the listener used for the rest of validation. + if (this.needsRevalidation(owner, generation)) return false; + const listenerDatabase = await this.bounded(owner, () => owner.connection!.query('SELECT current_database() AS database') + .catch(error => { throw new Error(`PostgreSQL observation could not verify the listener database for tenant '${owner.tenant}'`, { cause: error }); })); + if (listenerDatabase.rows[0]?.database !== identity.database) + throw new Error(this.mappingFailure(owner)); + await this.validate(owner, identity); + this.requireLiveLease(owner, lease); + if (this.needsRevalidation(owner, generation)) return false; + this.routeLease(owner, lease, identity); + return true; + } + + private canRetryLease(owner: Entry, lease: Lease, generation: number): boolean { + return !lease.released && !owner.dead && this.needsRevalidation(owner, generation); + } + + private mappingFailure(entry: Entry): string { + return `PostgreSQL observation database/table mapping differs between reader and listener for tenant '${entry.tenant}'`; + } + + private requireLiveLease(owner: Entry, lease: Lease): void { + if (lease.released || owner.dead) throw new Error('Drizzle observation was closed'); + } + + private needsRevalidation(owner: Entry, generation: number): boolean { + return owner.wasReady && (generation !== owner.generation || !owner.available); + } + + private routeLease(owner: Entry, lease: Lease, identity: PostgreSQLTableIdentity): void { + const existing = owner.identities.get(lease.table); + if (existing && this.mappingDiffers(existing, identity)) + throw new Error(this.mappingFailure(owner)); + owner.identities.set(lease.table, identity); + lease.key = identity.key; + let routed = owner.routing.get(identity.key); + if (!routed) { routed = new Set(); owner.routing.set(identity.key, routed); } + routed.add(lease); + if (!owner.wasReady) { + owner.available = true; + owner.wasReady = true; + owner.resolveReady(owner.generation); + this.heartbeat(owner); + } + } + + private mappingDiffers(first: Pick, second: { key: string; database: string | undefined }): boolean { + return first.key !== second.key || first.database !== second.database; + } + + private isLiveGeneration(entry: Entry, generation: number): boolean { + return !entry.dead && generation === entry.generation; + } + private async start(entry: Entry): Promise { const previousClose = this.#tenantCloses.get(entry.tenant); // A failed close is reported on disposal, not inherited by the next lease. The socket-close barrier stays @@ -131,6 +167,19 @@ export class PostgreSQLObservationManager { const connection = await this.bounded(entry, () => Promise.resolve(this.options.listener(entry.tenant, { signal: entry.controller.signal })), late => { void this.scheduleClose(entry, late, false).catch(() => {}); }, true, true); if (entry.dead) { await this.scheduleClose(entry, connection, false); throw new Error('Drizzle observation was closed'); } + this.requireDedicatedConnection(entry, connection); + entry.connection = connection; + connection.onDisconnect(error => { + if (this.isLiveGeneration(entry, generation)) + this.lost(entry, new Error(`PostgreSQL change listener lost: ${error ? 'connection failure' : 'connection closed'}`)); + }); + connection.onNotification((channel, payload) => this.notify(entry, generation, channel, payload)); + await this.bounded(entry, () => connection.connect()); + await this.bounded(entry, () => connection.query('LISTEN "arc_changes"')); + if (!this.isLiveGeneration(entry, generation)) throw new Error('Drizzle observation was closed'); + } + + private requireDedicatedConnection(entry: Entry, connection: PostgreSQLListenerConnection): void { if (!connection || typeof connection.connect !== 'function' || typeof connection.query !== 'function' || typeof connection.close !== 'function' || typeof connection.onNotification !== 'function' || typeof connection.onDisconnect !== 'function' || 'release' in connection) { @@ -139,19 +188,12 @@ export class PostgreSQLObservationManager { void this.scheduleClose(entry, connection, false).catch(() => {}); throw new Error('PostgreSQL listener requires a dedicated connection'); } - entry.connection = connection; - connection.onDisconnect(error => { - if (!entry.dead && generation === entry.generation) - this.lost(entry, new Error(`PostgreSQL change listener lost: ${error ? 'connection failure' : 'connection closed'}`)); - }); - connection.onNotification((channel, payload) => { - if (entry.dead || generation !== entry.generation || channel !== 'arc_changes' || !payload) return; - // The first read starts after routing; earlier commits are already visible to it. - for (const lease of [...entry.routing.get(payload) ?? []]) if (!lease.released) lease.changed(); - }); - await this.bounded(entry, () => connection.connect()); - await this.bounded(entry, () => connection.query('LISTEN "arc_changes"')); - if (entry.dead || generation !== entry.generation) throw new Error('Drizzle observation was closed'); + } + + private notify(entry: Entry, generation: number, channel: string, payload?: string): void { + if (!this.isLiveGeneration(entry, generation) || channel !== 'arc_changes' || !payload) return; + // The first read starts after routing; earlier commits are already visible to it. + for (const lease of [...entry.routing.get(payload) ?? []]) if (!lease.released) lease.changed(); } private async validate(entry: Entry, identity: PostgreSQLTableIdentity): Promise { @@ -165,25 +207,39 @@ export class PostgreSQLObservationManager { JOIN pg_catalog.pg_language l ON l.oid = p.prolang WHERE t.tgrelid = $1::oid AND t.tgname = 'arc_changes_v1'`, [identity.oid])); } catch (error) { - const permissionDenied = (error as { code?: unknown })?.code === '42501'; - if (entry.wasReady && !entry.available && !permissionDenied) - this.lost(entry, new Error('PostgreSQL change listener lost: validation failure')); - const message = `PostgreSQL observation cannot inspect triggers for '${identity.key}'`; - if (permissionDenied) throw new DefinitivePostgreSQLObservationError(`${message}; check catalog permissions`, { cause: error }); - throw new Error(message, { cause: error }); - } - const row = result.rows[0]; - let reason = 'missing'; - if (row) { - if (row.tgenabled !== 'O' && row.tgenabled !== 'A') reason = 'disabled'; - else if (Number(row.tgtype) !== 60 || row.no_predicate !== true || String(row.attributes).trim() !== '' || - row.tgisinternal !== false || String(row.constraint_oid) !== '0') reason = 'events'; - else if (row.proname !== 'arc_notify_changes_v1' || row.function_schema !== identity.schema || - Number(row.pronargs) !== 0 || row.returns_trigger !== true || row.lanname !== 'plpgsql') reason = 'function version'; - else if (Number(row.tgnargs) !== 1 || row.arguments !== `${Buffer.from('arc_changes').toString('hex')}00`) reason = 'channel'; - else return; + this.handleValidationFailure(entry, identity, error); } - throw new DefinitivePostgreSQLObservationError(`PostgreSQL observation trigger invalid for '${identity.key}': ${reason}; apply postgresqlChangeTrigger(...) through an application migration`); + const reason = this.triggerFailure(result.rows[0], identity.schema); + if (reason) + throw new DefinitivePostgreSQLObservationError(`PostgreSQL observation trigger invalid for '${identity.key}': ${reason}; apply postgresqlChangeTrigger(...) through an application migration`); + } + + private handleValidationFailure(entry: Entry, identity: PostgreSQLTableIdentity, error: unknown): never { + const permissionDenied = (error as { code?: unknown })?.code === '42501'; + if (entry.wasReady && !entry.available && !permissionDenied) + this.lost(entry, new Error('PostgreSQL change listener lost: validation failure')); + const message = `PostgreSQL observation cannot inspect triggers for '${identity.key}'`; + if (permissionDenied) throw new DefinitivePostgreSQLObservationError(`${message}; check catalog permissions`, { cause: error }); + throw new Error(message, { cause: error }); + } + + private triggerFailure(row: Record | undefined, schema: string): string | undefined { + if (!row) return 'missing'; + if (row.tgenabled !== 'O' && row.tgenabled !== 'A') return 'disabled'; + if (!this.hasExpectedEvents(row)) return 'events'; + if (!this.hasExpectedFunction(row, schema)) return 'function version'; + if (Number(row.tgnargs) !== 1 || row.arguments !== `${Buffer.from('arc_changes').toString('hex')}00`) return 'channel'; + return undefined; + } + + private hasExpectedEvents(row: Record): boolean { + return Number(row.tgtype) === 60 && row.no_predicate === true && String(row.attributes).trim() === '' + && row.tgisinternal === false && String(row.constraint_oid) === '0'; + } + + private hasExpectedFunction(row: Record, schema: string): boolean { + return row.proname === 'arc_notify_changes_v1' && row.function_schema === schema + && Number(row.pronargs) === 0 && row.returns_trigger === true && row.lanname === 'plpgsql'; } private heartbeat(entry: Entry): void { @@ -276,49 +332,72 @@ export class PostgreSQLObservationManager { // trigger may silently redirect a pre-existing observation. for (const lease of [...entry.leases]) { if (lease.released || !lease.key) continue; - try { - const identity = await this.bounded(entry, () => resolvePostgreSQLTable(lease.database, lease.table, entry.tenant), undefined, false); - if (identity.key !== lease.key || identity.database !== entry.identities.get(lease.table)?.database) - throw new DefinitivePostgreSQLObservationError(`PostgreSQL observation database/table mapping differs between reader and listener for tenant '${entry.tenant}'`); - const listenerDatabase = await this.bounded(entry, () => entry.connection!.query('SELECT current_database() AS database') - .catch(error => { - if (!entry.dead && generation === entry.generation) - this.lost(entry, new Error('PostgreSQL change listener lost: database verification failure')); - throw error; - })); - if (listenerDatabase.rows[0]?.database !== identity.database) - throw new DefinitivePostgreSQLObservationError(`PostgreSQL observation database/table mapping differs between reader and listener for tenant '${entry.tenant}'`); - await this.validate(entry, identity); - if (entry.controller.signal.aborted) throw new Error('PostgreSQL listener operation timed out'); - if (!lease.released && !entry.dead) entry.identities.set(lease.table, identity); - } catch (error) { - if (entry.controller.signal.aborted) throw error; - // Only an outcome a retry cannot change fails the observation; a pool error or - // reader timeout spends a bounded retry like any other failed attempt. - if (error instanceof DefinitivePostgreSQLObservationError) { - if (!lease.released) lease.fail(error); - continue; - } - throw new ReaderRevalidationError('PostgreSQL change listener lost: reader revalidation failure', { cause: error }); - } + await this.revalidateLease(entry, lease, generation); } - if (entry.dead || generation !== entry.generation || !entry.leases.size) return; - entry.available = true; - entry.lossReason = undefined; - entry.lossCause = undefined; - entry.resolveReady(generation); - this.heartbeat(entry); - for (const lease of [...entry.leases]) if (!lease.released && lease.key) lease.changed(true); + this.completeRecovery(entry, generation); return; } catch (error) { if (entry.dead) return; - if (!entry.controller.signal.aborted) - this.lost(entry, error instanceof ReaderRevalidationError ? error : new Error('PostgreSQL change listener lost: recovery attempt failed')); + this.handleRecoveryFailure(entry, error); // A failed attempt never resets the retry budget; a successful heartbeat does. } } } + private handleRecoveryFailure(entry: Entry, error: unknown): void { + if (!entry.controller.signal.aborted) + this.lost(entry, error instanceof ReaderRevalidationError ? error : new Error('PostgreSQL change listener lost: recovery attempt failed')); + } + + private async revalidateLease(entry: Entry, lease: Lease, generation: number): Promise { + try { + const identity = await this.bounded(entry, () => resolvePostgreSQLTable(lease.database, lease.table, entry.tenant), undefined, false); + this.requireExistingMapping(entry, lease, identity); + const listenerDatabase = await this.bounded(entry, () => entry.connection!.query('SELECT current_database() AS database') + .catch(error => { this.listenerDatabaseFailed(entry, generation, error); })); + if (listenerDatabase.rows[0]?.database !== identity.database) + throw new DefinitivePostgreSQLObservationError(this.mappingFailure(entry)); + await this.validate(entry, identity); + if (entry.controller.signal.aborted) throw new Error('PostgreSQL listener operation timed out'); + if (!lease.released && !entry.dead) entry.identities.set(lease.table, identity); + } catch (error) { + this.handleRevalidationFailure(entry, lease, error); + } + } + + private requireExistingMapping(entry: Entry, lease: Lease, identity: PostgreSQLTableIdentity): void { + const existing = { key: lease.key!, database: entry.identities.get(lease.table)?.database }; + if (this.mappingDiffers(identity, existing)) + throw new DefinitivePostgreSQLObservationError(this.mappingFailure(entry)); + } + + private listenerDatabaseFailed(entry: Entry, generation: number, error: unknown): never { + if (this.isLiveGeneration(entry, generation)) + this.lost(entry, new Error('PostgreSQL change listener lost: database verification failure')); + throw error; + } + + private handleRevalidationFailure(entry: Entry, lease: Lease, error: unknown): void { + if (entry.controller.signal.aborted) throw error; + // Only an outcome a retry cannot change fails the observation; a pool error or + // reader timeout spends a bounded retry like any other failed attempt. + if (error instanceof DefinitivePostgreSQLObservationError) { + if (!lease.released) lease.fail(error); + return; + } + throw new ReaderRevalidationError('PostgreSQL change listener lost: reader revalidation failure', { cause: error }); + } + + private completeRecovery(entry: Entry, generation: number): void { + if (entry.dead || generation !== entry.generation || !entry.leases.size) return; + entry.available = true; + entry.lossReason = undefined; + entry.lossCause = undefined; + entry.resolveReady(generation); + this.heartbeat(entry); + for (const lease of [...entry.leases]) if (!lease.released && lease.key) lease.changed(true); + } + private terminate(entry: Entry, error: Error): void { if (entry.dead) return; const leases = [...entry.leases]; @@ -333,10 +412,7 @@ export class PostgreSQLObservationManager { const leases = complete ? [...entry.leases] : []; entry.dead = true; entry.generation++; - entry.controller.abort(); - if (entry.timer) clearTimeout(entry.timer); - if (entry.retryTimer) clearTimeout(entry.retryTimer); - entry.retryWake?.(); + this.cancelEntry(entry); entry.rejectReady(new Error('Drizzle observation was closed')); entry.routing.clear(); entry.identities.clear(); for (const lease of leases) if (!lease.released) lease.complete?.(); @@ -344,6 +420,13 @@ export class PostgreSQLObservationManager { if (entry.connection) this.scheduleClose(entry, entry.connection); } + private cancelEntry(entry: Entry): void { + entry.controller.abort(); + if (entry.timer) clearTimeout(entry.timer); + if (entry.retryTimer) clearTimeout(entry.retryTimer); + entry.retryWake?.(); + } + private scheduleClose(entry: Entry, connection: PostgreSQLListenerConnection, serialize = true): Promise { const ending = Promise.resolve().then(() => connection.close()); if (serialize) { diff --git a/Source/Drizzle/for_PostgreSQLObservationManager/when_acquiring/with_trigger_variants.ts b/Source/Drizzle/for_PostgreSQLObservationManager/when_acquiring/with_trigger_variants.ts new file mode 100644 index 00000000..5223777d --- /dev/null +++ b/Source/Drizzle/for_PostgreSQLObservationManager/when_acquiring/with_trigger_variants.ts @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { database, Listener, managerFor, table } from '../given/a_manager.js'; + +describe('when acquiring with an incompatible PostgreSQL trigger', () => { + for (const [reason, changes] of [ + ['disabled', { tgenabled: 'D' }], + ['events', { tgtype: 61 }], + ['events', { no_predicate: false }], + ['events', { attributes: '1' }], + ['events', { tgisinternal: true }], + ['events', { constraint_oid: '2' }], + ['function version', { proname: 'old_notify' }], + ['function version', { function_schema: 'other' }], + ['function version', { pronargs: 1 }], + ['function version', { returns_trigger: false }], + ['function version', { lanname: 'sql' }], + ['channel', { tgnargs: 2 }], + ['channel', { arguments: '' }] + ] as [string, Record][]) { + it(`should reject ${Object.keys(changes)[0]} as ${reason}`, async () => { + const listener = new Listener(); + const query = listener.query.bind(listener); + listener.query = async statement => { + const result = await query(statement); + return statement.includes('pg_trigger') ? { rows: [{ ...result.rows[0], ...changes }] } : result; + }; + const manager = managerFor(() => listener); + const lease = manager.acquire('tenant', database, table, () => {}, () => {}); + try { + await lease.ready.should.be.rejectedWith(`PostgreSQL observation trigger invalid for 'app.tasks': ${reason}; apply postgresqlChangeTrigger(...) through an application migration`); + listener.closeCount.should.equal(1); + } finally { lease.release(); await manager[Symbol.asyncDispose](); } + }); + } + + it('should accept an always enabled trigger and release its routing only once', async () => { + const listener = new Listener(); + const query = listener.query.bind(listener); + listener.query = async statement => { + const result = await query(statement); + return statement.includes('pg_trigger') ? { rows: [{ ...result.rows[0], tgenabled: 'A' }] } : result; + }; + const manager = managerFor(() => listener); + const changes: string[] = []; + const first = manager.acquire('tenant', database, table, () => changes.push('first'), () => {}); + const second = manager.acquire('tenant', database, table, () => changes.push('second'), () => {}); + try { + await Promise.all([first.ready, second.ready]); + first.release(); + first.release(); + listener.notification?.('arc_changes', 'app.tasks'); + changes.should.deep.equal(['second']); + listener.closeCount.should.equal(0); + } finally { first.release(); second.release(); await manager[Symbol.asyncDispose](); } + }); +}); diff --git a/Source/Drizzle/for_PostgreSQLObservationManager/when_recovering/with_listener_database_failures.ts b/Source/Drizzle/for_PostgreSQLObservationManager/when_recovering/with_listener_database_failures.ts new file mode 100644 index 00000000..7cf809d3 --- /dev/null +++ b/Source/Drizzle/for_PostgreSQLObservationManager/when_recovering/with_listener_database_failures.ts @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { vi } from 'vitest'; +import { DrizzleObservation } from '../../DrizzleObservation.js'; +import { PostgreSQLObservationManager } from '../../PostgreSQLObservationManager.js'; +import { database, Listener, table } from '../given/a_manager.js'; + +describe('when verifying the listener database during PostgreSQL recovery', () => { + it('should spend a retry on a query failure and catch up on the next listener', async () => { + const listeners = [new Listener(), new Listener(), new Listener()]; + const query = listeners[1]!.query.bind(listeners[1]); + listeners[1]!.query = async statement => { + if (statement.includes('current_database')) throw new Error('connection unavailable'); + return query(statement); + }; + let connections = 0; + const manager = new PostgreSQLObservationManager({ mode: DrizzleObservation.PostgreSQLNotify, + listener: () => listeners[connections++]! }, 60_000, 100, [1, 1]); + const changes: boolean[] = []; + const failures: Error[] = []; + const lease = manager.acquire('tenant', database, table, forced => changes.push(forced ?? false), error => failures.push(error)); + try { + await lease.ready; + listeners[0]!.disconnect?.(); + await vi.waitFor(() => changes.should.deep.equal([true])); + connections.should.equal(3); + failures.should.have.lengthOf(0); + listeners[1]!.closeCount.should.equal(1); + } finally { lease.release(); await manager[Symbol.asyncDispose](); } + }); + + it('should fail the lease definitively when the listener database differs', async () => { + const first = new Listener(); + const next = new Listener(); + const query = next.query.bind(next); + next.query = async statement => statement.includes('current_database') ? { rows: [{ database: 'other' }] } : query(statement); + let connections = 0; + const manager = new PostgreSQLObservationManager({ mode: DrizzleObservation.PostgreSQLNotify, + listener: () => ++connections === 1 ? first : next }, 60_000, 100, [1]); + const failures: Error[] = []; + const lease = manager.acquire('tenant', database, table, () => {}, error => failures.push(error)); + try { + await lease.ready; + first.disconnect?.(); + await vi.waitFor(() => failures.should.have.lengthOf(1)); + failures[0]!.message.should.equal("PostgreSQL observation database/table mapping differs between reader and listener for tenant 'tenant'"); + connections.should.equal(2); + } finally { lease.release(); await manager[Symbol.asyncDispose](); } + }); +}); diff --git a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts index d794fa76..e984332d 100644 --- a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts +++ b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts @@ -10,6 +10,20 @@ import { fieldName, isPackageSymbol, isStandardType, isTypeFrom, originalSymbol import { isScalarSortConcept } from './isScalarSortConcept.js'; import { conceptValue } from './conceptValue.js'; +interface TypeResolution { + readonly type: ts.Type; + readonly location: ts.Node; + readonly optional: boolean; + readonly nullable: boolean; + readonly defined: readonly ts.Type[]; +} + +interface ModelIdentity { + readonly key: string; + readonly name: string; + readonly deprecated?: string; +} + const fundamentals = new Set(['Guid', 'DateOnly', 'TimeOnly', 'TimeSpan']); const primitive = (text: string, constructor: string): SourceType => ({ text, constructor, enumerable: false, nullable: false, void: false }); export class SourceTypeResolver { @@ -39,80 +53,137 @@ export class SourceTypeResolver { } resolve(type: ts.Type, location: ts.Node, optional = false): SourceType { const parts = type.isUnion() ? type.types : [type]; - const nullable = optional || parts.some(part => !!(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) || - parts.length > 1 && !!(part.flags & ts.TypeFlags.Void)); - const defined = parts.filter(part => !(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) && - !(parts.length > 1 && part.flags & ts.TypeFlags.Void)); + const defined = parts.filter(part => !this.isAbsentPart(part, parts.length)); + const nullable = optional || defined.length !== parts.length; + const context: TypeResolution = { type, location, optional, nullable, defined }; + return this.resolveUnion(context) ?? this.resolvePrimitive(context) ?? this.resolveBuiltIn(context) + ?? this.resolveArray(context) ?? this.resolveConceptOrModel(context); + } + private isAbsentPart(part: ts.Type, count: number): boolean { + return !!(part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) || count > 1 && !!(part.flags & ts.TypeFlags.Void); + } + private resolveUnion(context: TypeResolution): SourceType | undefined { + const { type, location, nullable, defined } = context; if (defined.length === 1 && defined[0] !== type) return { ...this.resolve(defined[0]!, location), nullable }; - if (defined.length > 1 && defined.every(part => !!(part.flags & ts.TypeFlags.BooleanLiteral))) - return { ...primitive('boolean', 'Boolean'), nullable }; - const firstEnumMember = defined[0]?.symbol?.declarations?.find(ts.isEnumMember); - const enumSymbol = type.aliasSymbol ?? (firstEnumMember && this.checker.getSymbolAtLocation(firstEnumMember.parent.name)); - if (enumSymbol?.declarations?.some(ts.isEnumDeclaration) && defined.every(part => - part.symbol?.declarations?.some(declaration => ts.isEnumMember(declaration) && declaration.parent === enumSymbol.declarations?.[0]))) - return this.resolveEnum(enumSymbol, type, location, nullable); - if (defined.length > 1 && defined.every(part => !!(part.flags & ts.TypeFlags.StringLiteral))) + if (this.isLiteralUnion(defined, ts.TypeFlags.BooleanLiteral)) return { ...primitive('boolean', 'Boolean'), nullable }; + const enumSymbol = this.unionEnum(type, defined); + if (enumSymbol) return this.resolveEnum(enumSymbol, type, location, nullable); + if (this.isLiteralUnion(defined, ts.TypeFlags.StringLiteral)) return { ...primitive(defined.map(part => JSON.stringify((part as ts.StringLiteralType).value)).join(' | '), 'String'), nullable }; + return undefined; + } + private isLiteralUnion(defined: readonly ts.Type[], flag: ts.TypeFlags): boolean { + return defined.length > 1 && defined.every(part => !!(part.flags & flag)); + } + private unionEnum(type: ts.Type, defined: readonly ts.Type[]): ts.Symbol | undefined { + const symbol = type.aliasSymbol ?? this.firstEnumSymbol(defined); + if (!this.isEnumSymbol(symbol)) return undefined; + return defined.every(part => this.isEnumPart(part, symbol!)) ? symbol : undefined; + } + private firstEnumSymbol(defined: readonly ts.Type[]): ts.Symbol | undefined { + const member = defined[0]?.symbol?.declarations?.find(ts.isEnumMember); + return member && this.checker.getSymbolAtLocation(member.parent.name); + } + private isEnumSymbol(symbol: ts.Symbol | undefined): boolean { + return !!symbol?.declarations?.some(ts.isEnumDeclaration); + } + private isEnumPart(part: ts.Type, symbol: ts.Symbol): boolean | undefined { + return part.symbol?.declarations?.some(declaration => ts.isEnumMember(declaration) && declaration.parent === symbol.declarations?.[0]); + } + private resolvePrimitive({ type, nullable }: TypeResolution): SourceType | undefined { if (type.flags & ts.TypeFlags.StringLike) return { ...primitive('string', 'String'), nullable }; if (type.flags & ts.TypeFlags.NumberLike) return { ...primitive('number', 'Number'), nullable }; if (type.flags & ts.TypeFlags.BooleanLike) return { ...primitive('boolean', 'Boolean'), nullable }; if (type.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) return { ...primitive('void', 'Object'), void: true, nullable }; - const symbol = type.aliasSymbol ?? type.getSymbol(); - const name = symbol?.getName(); + return undefined; + } + private resolveBuiltIn({ type, location, optional, nullable }: TypeResolution): SourceType | undefined { if (isStandardType(type, 'Promise') || isStandardType(type, 'PromiseLike')) { const argument = this.checker.getTypeArguments(type as ts.TypeReference)[0]; if (argument) return this.resolve(argument, location, optional); } if (isStandardType(type, 'Date')) return { ...primitive('Date', 'Date'), nullable }; - if (name && fundamentals.has(name) && isTypeFrom(this.checker, type, name, '@cratis/fundamentals')) + const name = this.typeSymbol(type)?.getName(); + if (name && this.isFundamentalsType(type, name)) return { ...primitive(name, name), package: '@cratis/fundamentals', nullable }; - if (this.checker.isArrayType(type) || this.checker.isTupleType(type)) { - if (this.checker.isTupleType(type)) return this.unsupported(type, location); - const element = this.checker.getTypeArguments(type as ts.TypeReference)[0]; - if (!element) return this.unsupported(type, location); - const resolved = this.resolve(element, location); - if (resolved.enumerable || resolved.void || resolved.nullable) return this.unsupported(type, location); - return { ...resolved, text: resolved.text.includes(' | ') ? `(${resolved.text})[]` : `${resolved.text}[]`, enumerable: true, nullable }; - } + return undefined; + } + private isFundamentalsType(type: ts.Type, name: string): boolean { + return fundamentals.has(name) && isTypeFrom(this.checker, type, name, '@cratis/fundamentals'); + } + private resolveArray({ type, location, nullable }: TypeResolution): SourceType | undefined { + if (!this.checker.isArrayType(type) && !this.checker.isTupleType(type)) return undefined; + if (this.checker.isTupleType(type)) return this.unsupported(type, location); + const element = this.checker.getTypeArguments(type as ts.TypeReference)[0]; + if (!element) return this.unsupported(type, location); + const resolved = this.resolve(element, location); + if (!this.isArrayElement(resolved)) return this.unsupported(type, location); + return { ...resolved, text: resolved.text.includes(' | ') ? `(${resolved.text})[]` : `${resolved.text}[]`, enumerable: true, nullable }; + } + private isArrayElement(resolved: SourceType): boolean { + return !resolved.enumerable && !resolved.void && !resolved.nullable; + } + private resolveConceptOrModel(context: TypeResolution): SourceType { + const { type, location } = context; const direct = this.isDirectConcept(type); - if (direct) { - const base = type.getBaseTypes()?.find(candidate => isTypeFrom(this.checker, candidate, 'ConceptAs', '@cratis/fundamentals')); - const argument = this.checker.getTypeArguments(type as ts.TypeReference)[0] ?? - (base && this.checker.getTypeArguments(base as ts.TypeReference)[0]); - if (argument) { - const declaration = symbol?.declarations?.find(ts.isClassDeclaration); - if (declaration) this.contribute(declaration); - return this.resolve(argument, location, optional); - } - } - const declaration = symbol?.declarations?.find(ts.isClassDeclaration); + const resolved = direct ? this.resolveDirectConcept(context) : undefined; + if (resolved) return resolved; const value = direct || !this.scalarConceptSubclasses ? undefined : conceptValue(this.checker, type, location); - if (value) { - // Indirect and generic concepts travel as their value. The class they used to produce is still generated, deprecated. - const resolved = this.resolve(value, location, optional); - if (declaration && name && !declaration.getSourceFile().isDeclarationFile) { - const legacy = this.model(type, declaration, name, resolved.text); - if (legacy.mapped) return { ...legacy, nullable }; - } - return resolved; + if (value) return this.resolveLegacyConcept(context, value); + return this.resolveModelOrEnum(context); + } + private resolveDirectConcept({ type, location, optional }: TypeResolution): SourceType | undefined { + const base = type.getBaseTypes()?.find(candidate => isTypeFrom(this.checker, candidate, 'ConceptAs', '@cratis/fundamentals')); + const argument = this.checker.getTypeArguments(type as ts.TypeReference)[0] ?? + (base && this.checker.getTypeArguments(base as ts.TypeReference)[0]); + if (!argument) return undefined; + const declaration = this.classDeclaration(type); + if (declaration) this.contribute(declaration); + return this.resolve(argument, location, optional); + } + private resolveLegacyConcept({ type, location, optional, nullable }: TypeResolution, value: ts.Type): SourceType { + // Indirect and generic concepts travel as their value. The class they used to produce is still generated, deprecated. + const resolved = this.resolve(value, location, optional); + const declaration = this.classDeclaration(type); + const name = this.typeSymbol(type)?.getName(); + if (this.isLocalModel(declaration, name)) { + const legacy = this.model(type, declaration!, name!, resolved.text); + if (legacy.mapped) return { ...legacy, nullable }; } - if (symbol?.declarations?.some(ts.isEnumDeclaration)) return this.resolveEnum(symbol, type, location, nullable); - if (declaration && name && !declaration.getSourceFile().isDeclarationFile) return { ...this.model(type, declaration, name), nullable }; + return resolved; + } + private resolveModelOrEnum({ type, location, nullable }: TypeResolution): SourceType { + const symbol = this.typeSymbol(type); + if (this.isEnumSymbol(symbol)) return this.resolveEnum(symbol!, type, location, nullable); + const declaration = this.classDeclaration(type); + const name = symbol?.getName(); + if (this.isLocalModel(declaration, name)) return { ...this.model(type, declaration!, name!), nullable }; return this.unsupported(type, location); } + private typeSymbol(type: ts.Type): ts.Symbol | undefined { + return type.aliasSymbol ?? type.getSymbol(); + } + private classDeclaration(type: ts.Type): ts.ClassDeclaration | undefined { + return this.typeSymbol(type)?.declarations?.find(ts.isClassDeclaration); + } + private isLocalModel(declaration: ts.ClassDeclaration | undefined, name: string | undefined): boolean { + return !!declaration && !!name && !declaration.getSourceFile().isDeclarationFile; + } private isDirectConcept(type: ts.Type): boolean { return isTypeFrom(this.checker, type, 'ConceptAs', '@cratis/fundamentals') || !!type.getBaseTypes()?.some(base => isTypeFrom(this.checker, base, 'ConceptAs', '@cratis/fundamentals')); } /** A base that is itself an indirect concept keeps referencing its deprecated class, as it did before. */ private resolveBase(type: ts.Type, location: ts.Node): SourceType { - const symbol = type.aliasSymbol ?? type.getSymbol(); - const declaration = symbol?.declarations?.find(ts.isClassDeclaration); - const value = this.scalarConceptSubclasses && declaration && !declaration.getSourceFile().isDeclarationFile && !this.isDirectConcept(type) ? - conceptValue(this.checker, type, location) : undefined; + const symbol = this.typeSymbol(type); + const declaration = this.classDeclaration(type); + const value = this.isLegacyConceptBase(type, declaration) ? conceptValue(this.checker, type, location) : undefined; if (value) return this.model(type, declaration!, symbol!.getName(), this.resolve(value, location).text); return this.resolve(type, location); } + private isLegacyConceptBase(type: ts.Type, declaration: ts.ClassDeclaration | undefined): boolean { + return this.scalarConceptSubclasses && !!declaration && !declaration.getSourceFile().isDeclarationFile && !this.isDirectConcept(type); + } /** Resolve a model class; `deprecated` is the underlying type text when the class stands for a concept subclass. */ private model(type: ts.Type, declaration: ts.ClassDeclaration, name: string, deprecated?: string): SourceType { this.contribute(declaration); @@ -122,70 +193,99 @@ export class SourceTypeResolver { this.checkIdentity(key, declaration); const existing = this.models.get(key); // A class shared by instantiations with different values cannot name one underlying type. - if (existing?.deprecated && deprecated !== undefined && existing.deprecated.value !== deprecated) - this.models.set(key, { ...existing, deprecated: {} }); - if (!existing) { - const deprecation = deprecated === undefined ? undefined : { value: declaration.typeParameters?.length ? undefined : deprecated }; - this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields: [], ...(deprecation ? { deprecated: deprecation } : {}) }); - const fields: SourceField[] = declaration.members.filter(ts.isPropertyDeclaration).filter(member => { - const decorators = ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []; - if (!decorators.some(decorator => { - const expression = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression; - return isPackageSymbol(this.checker, expression, 'field', '@cratis/fundamentals'); - })) return false; - return true; - }).map(member => { - const name = fieldName(member.name); - const decorated = (label: string): boolean => (ts.getDecorators(member) ?? []).some(decorator => { - const expression = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression; - return isPackageSymbol(this.checker, expression, label, '@cratis/arc.core'); - }); - const optional = decorated('optional') || decorated('defaultValue') || - this.generatedMetadata && (!!member.questionToken || !!member.initializer); - if (member.questionToken && !optional) - throw new Error(`${member.getSourceFile().fileName}: ${name} TypeScript ? disagrees with Arc field optionality; add @optional() or remove ?`); - const enumeration = (ts.getDecorators(member) ?? []).map(decorator => decorator.expression).find(expression => - ts.isCallExpression(expression) && isPackageSymbol(this.checker, expression.expression, 'enumeration', '@cratis/arc.core')); - const argument = enumeration && ts.isCallExpression(enumeration) ? enumeration.arguments[0] : undefined; - const enumSymbol = argument && originalSymbol(this.checker, argument); - const type = enumSymbol?.declarations?.some(ts.isEnumDeclaration) ? this.checker.getDeclaredTypeOfSymbol(enumSymbol) : this.checker.getTypeAtLocation(member); - const propertyType = this.checker.getTypeAtLocation(member); - const nullable = decorated('nullable') || this.generatedMetadata && propertyType.isUnion() && - propertyType.types.some(part => !!(part.flags & ts.TypeFlags.Null)); - return { name, type: this.resolve(type, member, optional || nullable), optional, nullable, - ...(isScalarSortConcept(this.checker, type, member) ? { scalarSortConcept: true } : {}) }; - }); - const baseType = type.getBaseTypes()?.find(base => base.symbol?.declarations?.some(ts.isClassDeclaration) && - !base.symbol.declarations.every(origin => origin.getSourceFile().isDeclarationFile)); - const base = baseType ? this.resolveBase(baseType, declaration) : undefined; - const basePackage = base?.mapped ? base.package : undefined; - const derived = (ts.getDecorators(declaration) ?? []).map(decorator => decorator.expression).find(expression => { - if (!ts.isCallExpression(expression)) return false; - const symbol = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name : expression.expression; - return isPackageSymbol(this.checker, symbol, 'derivedType', '@cratis/fundamentals'); - }); - const derivedTypeId = derived && ts.isCallExpression(derived) && derived.arguments[0] && ts.isStringLiteral(derived.arguments[0]) ? derived.arguments[0].text : undefined; - this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields, - base: basePackage ? base!.text : base?.model, baseKey: base?.modelKey, basePackage, derivedTypeId, - ...(deprecation ? { deprecated: deprecation } : {}) }); - } + if (this.hasConflictingDeprecation(existing, deprecated)) this.models.set(key, { ...existing!, deprecated: {} }); + if (!existing) this.defineModel(type, declaration, { key, name, deprecated }); return { ...primitive(name, name), model: name, modelKey: key }; } + private hasConflictingDeprecation(existing: SourceModel | undefined, deprecated: string | undefined): boolean { + return !!existing?.deprecated && deprecated !== undefined && existing.deprecated.value !== deprecated; + } + private defineModel(type: ts.Type, declaration: ts.ClassDeclaration, { key, name, deprecated }: ModelIdentity): void { + const deprecation = deprecated === undefined ? undefined : { value: declaration.typeParameters?.length ? undefined : deprecated }; + this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields: [], ...(deprecation ? { deprecated: deprecation } : {}) }); + const fields = declaration.members.filter(ts.isPropertyDeclaration).filter(member => this.isField(member)).map(member => this.resolveField(member)); + const base = this.modelBase(type, declaration); + const derivedTypeId = this.derivedTypeId(declaration); + this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields, + ...base, derivedTypeId, ...(deprecation ? { deprecated: deprecation } : {}) }); + } + private isDecorator(decorator: ts.Decorator, label: string, packageName: string): boolean { + const expression = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression; + return isPackageSymbol(this.checker, expression, label, packageName); + } + private isField(member: ts.PropertyDeclaration): boolean { + const decorators = ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []; + return decorators.some(decorator => this.isDecorator(decorator, 'field', '@cratis/fundamentals')); + } + private hasArcDecorator(member: ts.PropertyDeclaration, label: string): boolean { + return (ts.getDecorators(member) ?? []).some(decorator => this.isDecorator(decorator, label, '@cratis/arc.core')); + } + private isOptional(member: ts.PropertyDeclaration): boolean { + return this.hasArcDecorator(member, 'optional') || this.hasArcDecorator(member, 'defaultValue') || this.hasGeneratedOptionality(member); + } + private hasGeneratedOptionality(member: ts.PropertyDeclaration): boolean { + return this.generatedMetadata && (!!member.questionToken || !!member.initializer); + } + private hasGeneratedNullability(type: ts.Type): boolean { + return this.generatedMetadata && type.isUnion() && type.types.some(part => !!(part.flags & ts.TypeFlags.Null)); + } + private resolveField(member: ts.PropertyDeclaration): SourceField { + const name = fieldName(member.name); + const optional = this.isOptional(member); + if (member.questionToken && !optional) + throw new Error(`${member.getSourceFile().fileName}: ${name} TypeScript ? disagrees with Arc field optionality; add @optional() or remove ?`); + const type = this.fieldType(member); + const propertyType = this.checker.getTypeAtLocation(member); + const nullable = this.hasArcDecorator(member, 'nullable') || this.hasGeneratedNullability(propertyType); + return { name, type: this.resolve(type, member, optional || nullable), optional, nullable, + ...(isScalarSortConcept(this.checker, type, member) ? { scalarSortConcept: true } : {}) }; + } + private fieldType(member: ts.PropertyDeclaration): ts.Type { + const enumeration = (ts.getDecorators(member) ?? []).map(decorator => decorator.expression).find(expression => + ts.isCallExpression(expression) && isPackageSymbol(this.checker, expression.expression, 'enumeration', '@cratis/arc.core')); + const argument = enumeration && ts.isCallExpression(enumeration) ? enumeration.arguments[0] : undefined; + const enumSymbol = argument && originalSymbol(this.checker, argument); + return this.isEnumSymbol(enumSymbol) ? this.checker.getDeclaredTypeOfSymbol(enumSymbol!) : this.checker.getTypeAtLocation(member); + } + private modelBase(type: ts.Type, declaration: ts.ClassDeclaration): Pick { + const baseType = type.getBaseTypes()?.find(base => this.isModelBase(base)); + const base = baseType ? this.resolveBase(baseType, declaration) : undefined; + const basePackage = base?.mapped ? base.package : undefined; + return { base: basePackage ? base!.text : base?.model, baseKey: base?.modelKey, basePackage }; + } + private isModelBase(base: ts.BaseType): boolean | undefined { + return base.symbol?.declarations?.some(ts.isClassDeclaration) && + !base.symbol.declarations.every(origin => origin.getSourceFile().isDeclarationFile); + } + private derivedTypeId(declaration: ts.ClassDeclaration): string | undefined { + const derived = (ts.getDecorators(declaration) ?? []).map(decorator => decorator.expression).find(expression => { + if (!ts.isCallExpression(expression)) return false; + const symbol = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name : expression.expression; + return isPackageSymbol(this.checker, symbol, 'derivedType', '@cratis/fundamentals'); + }); + if (!derived || !ts.isCallExpression(derived)) return undefined; + const argument = derived.arguments[0]; + return argument && ts.isStringLiteral(argument) ? argument.text : undefined; + } private resolveEnum(symbol: ts.Symbol, type: ts.Type, location: ts.Node, nullable: boolean): SourceType { const declaration = symbol.declarations?.find(ts.isEnumDeclaration); if (!declaration) return this.unsupported(type, location); this.contribute(declaration); const name = symbol.getName(); const members = declaration.members.map(member => ({ name: fieldName(member.name), value: this.checker.getConstantValue(member) })); - if (members.some(member => typeof member.value !== 'number' && typeof member.value !== 'string')) return this.unsupported(type, location); + if (members.some(member => !this.isEnumValue(member.value))) return this.unsupported(type, location); const key = [this.namespace(declaration), name].filter(Boolean).join('.'); - const mapped = this.mapped(key, typeof members[0]?.value === 'string' ? 'String' : 'Number'); + const constructor = typeof members[0]?.value === 'string' ? 'String' : 'Number'; + const mapped = this.mapped(key, constructor); if (mapped) return { ...mapped, nullable }; this.checkIdentity(key, declaration); this.models.set(key, { kind: 'enum', name, namespace: this.namespace(declaration), fields: [], members: members as { name: string; value: number | string }[] }); - return { ...primitive(name, typeof members[0]?.value === 'string' ? 'String' : 'Number'), model: name, modelKey: key, nullable }; + return { ...primitive(name, constructor), model: name, modelKey: key, nullable }; + } + private isEnumValue(value: string | number | undefined): boolean { + return typeof value === 'number' || typeof value === 'string'; } private checkIdentity(key: string, declaration: ts.Declaration): void { const previous = this.declarations.get(key); diff --git a/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_type_shapes/given/Shapes.ts b/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_type_shapes/given/Shapes.ts new file mode 100644 index 00000000..dbf4217e --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_type_shapes/given/Shapes.ts @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { field } from '@cratis/fundamentals'; + +export enum Status { Open = 'open', Closed = 'closed' } +export enum Priority { Low, High } + +export class Shapes { + text!: string | null; + optionalVoid!: string | void; + flag!: true | false; + choice!: 'first' | 'second'; + choices!: ('first' | 'second')[] | null; + status!: Status; + priority!: Priority; + promised!: Promise; + promiseLike!: PromiseLike; + date!: Date; + empty!: void; + mixed!: string | number; + nested!: string[][]; + nullableElements!: (string | null)[]; + voidElements!: void[]; + tuple!: [string, number]; + unknownValue!: unknown; +} + +export class InferredFields { + @field(String) question?: string; + @field(String) initialized = 'value'; + @field(String) nullable!: string | null; + ignored!: string; +} + +export class RecursiveModel { + @field(Object) next!: RecursiveModel; +} diff --git a/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_type_shapes/with_unions_and_fields.ts b/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_type_shapes/with_unions_and_fields.ts new file mode 100644 index 00000000..2850e3c4 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_type_shapes/with_unions_and_fields.ts @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import ts from 'typescript'; +import { SourceTypeResolver } from '../../SourceTypeResolver.js'; + +const root = resolve('Source/Tools/ProxyGenerator/for_SourceTypeResolver/when_resolving_type_shapes/given'); +const path = resolve(root, 'Shapes.ts'); +const program = ts.createProgram([path], { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, strict: true, experimentalDecorators: true }); +const checker = program.getTypeChecker(); +const classes = program.getSourceFile(path)!.statements.filter(ts.isClassDeclaration); +const shapes = classes.find(declaration => declaration.name!.text === 'Shapes')!; + +function property(name: string): ts.PropertyDeclaration { + return shapes.members.filter(ts.isPropertyDeclaration).find(member => member.name.getText() === name)!; +} + +function resolveProperty(name: string, optional = false) { + const member = property(name); + return new SourceTypeResolver(checker, root).resolve(checker.getTypeAtLocation(member), member, optional); +} + +describe('when resolving source type shapes', () => { + it('should preserve nullable primitive, boolean, and literal unions', () => { + resolveProperty('text', true).should.include({ text: 'string', constructor: 'String', nullable: true }); + resolveProperty('flag').should.include({ text: 'boolean', constructor: 'Boolean', nullable: false }); + resolveProperty('choice').should.include({ text: '"first" | "second"', constructor: 'String' }); + resolveProperty('choices', true).should.include({ text: '("first" | "second")[]', enumerable: true, nullable: true }); + }); + it('should filter void union members and unwrap promise types', () => { + resolveProperty('optionalVoid').text.should.equal('string'); + resolveProperty('promised', true).should.include({ text: 'string', nullable: true }); + resolveProperty('promiseLike').text.should.equal('number'); + resolveProperty('empty').void.should.equal(true); + resolveProperty('date').constructor.should.equal('Date'); + }); + it('should preserve enum identities and members', () => { + const resolver = new SourceTypeResolver(checker, root); + for (const name of ['status', 'priority']) { + const member = property(name); + resolver.resolve(checker.getTypeAtLocation(member), member); + } + resolver.models.get('Status')!.members!.should.deep.equal([{ name: 'Open', value: 'open' }, { name: 'Closed', value: 'closed' }]); + resolver.models.get('Priority')!.members!.should.deep.equal([{ name: 'Low', value: 0 }, { name: 'High', value: 1 }]); + }); + for (const name of ['mixed', 'nested', 'nullableElements', 'voidElements', 'tuple', 'unknownValue']) { + it(`should reject ${name} with its source location`, () => { + const member = property(name); + const position = member.getSourceFile().getLineAndCharacterOfPosition(member.getStart()); + (() => resolveProperty(name)).should.throw(`${path}:${position.line + 1}:${position.character + 1}: Unsupported client type ${checker.typeToString(checker.getTypeAtLocation(member))}`); + }); + } + it('should preserve null without optional field metadata', () => { + resolveProperty('text').nullable.should.equal(true); + }); + it('should infer generated field optionality and nullability without including undecorated fields', () => { + const declaration = classes.find(item => item.name!.text === 'InferredFields')!; + const resolver = new SourceTypeResolver(checker, root, true); + resolver.resolve(checker.getTypeAtLocation(declaration), declaration); + resolver.models.get('InferredFields')!.fields.map(({ name, optional, nullable }) => ({ name, optional, nullable })).should.deep.equal([ + { name: 'question', optional: true, nullable: false }, + { name: 'initialized', optional: true, nullable: false }, + { name: 'nullable', optional: false, nullable: true } + ]); + }); + it('should reject a question token without Arc metadata', () => { + const declaration = classes.find(item => item.name!.text === 'InferredFields')!; + (() => new SourceTypeResolver(checker, root).resolve(checker.getTypeAtLocation(declaration), declaration)) + .should.throw(`${path}: question TypeScript ? disagrees with Arc field optionality; add @optional() or remove ?`); + }); + it('should register recursive models before resolving their fields', () => { + const declaration = classes.find(item => item.name!.text === 'RecursiveModel')!; + const resolver = new SourceTypeResolver(checker, root); + resolver.resolve(checker.getTypeAtLocation(declaration), declaration); + resolver.models.size.should.equal(1); + resolver.models.get('RecursiveModel')!.fields[0]!.type.modelKey!.should.equal('RecursiveModel'); + }); +});