Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/driver-memory-update-upsert-honest-types.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/driver-memory': minor
---

feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.

`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.

<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
11 changes: 11 additions & 0 deletions .changeset/idatadriver-update-declares-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': minor
---

feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)

**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.

What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.

<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->
5 changes: 5 additions & 0 deletions .changeset/metadata-database-loader-update-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata': patch
---

`DatabaseLoader`'s private driver-path update helper is typed with the `null` arm `IDataDriver.update()` now declares; both of its callers discard the result. No runtime behaviour changes.
10 changes: 7 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ describe('InMemoryDriver', () => {
{ active: false }
);

expect(updateResult.active).toBe(false);
// The declared type carries the not-found arm (#13878); a caller reading
// fields narrows first. The row exists, so the arm is not taken here.
expect(updateResult).not.toBeNull();
expect(updateResult!.active).toBe(false);

const results = await driver.find(testTable, { fields: ['active'] });
expect(results[0].active).toBe(false);
Expand DownExpand Up@@ -98,8 +101,9 @@ describe('InMemoryDriver', () => {
const originalCreatedAt = created.created_at;

const updated = await driver.update(testTable, '1', { name: 'Alice Updated' });
expect(updated.created_at).toBe(originalCreatedAt);
expect(updated.name).toBe('Alice Updated');
expect(updated).not.toBeNull();
expect(updated!.created_at).toBe(originalCreatedAt);
expect(updated!.name).toBe('Alice Updated');
});
});

Expand Down
23 changes: 20 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,15 @@ export class InMemoryDriver implements IDataDriver {
return { ...newRecord };
}

async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
/**
* Declared as the contract declares it (#13878): the `null` arm is the
* non-`strictMode` miss the behaviour pin has always held, and the explicit
* annotation is what keeps that arm visible to `tsc` — left to inference,
* the return type collapses to `any` through the backing store's
* `any[]` rows and the union is swallowed, so the published `.d.ts` read
* `Promise<any>` and no caller was ever asked to narrow.
*/
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
this.logger.debug('Update operation', { object, id });

const table = this.getTable(object);
Expand DownExpand Up@@ -698,7 +706,7 @@ export class InMemoryDriver implements IDataDriver {
return { ...updatedRecord };
}

async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Upsert operation', { object, conflictKeys });

const table = this.getTable(object);
Expand All@@ -712,7 +720,16 @@ export class InMemoryDriver implements IDataDriver {

if (existingRecord) {
this.logger.debug('Record exists, updating', { object, id: existingRecord.id });
return this.update(object, existingRecord.id, data, options);
const updated = await this.update(object, existingRecord.id, data, options);
// `existingRecord` was read from this same table above and nothing
// yields in between, so `update` cannot miss here: the `null` arm of
// its declared type is unreachable on this path. An upsert never
// answers "not found" — say so loudly rather than widen this door's
// declared return to carry an arm it can never produce (#13878).
if (updated === null) {
throw new Error(`Record with ID ${existingRecord.id} vanished from ${object} during upsert`);
}
return updated;
} else {
this.logger.debug('Record does not exist, creating', { object });
return this.create(object, data, options);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13878 — the declared return type of `update()` carries its not-found arm,
// and the driver's published type is the contract's, not `any`.
//
// `InMemoryDriver.update()` has always answered a missing id with `null` when
// `strictMode` is off (the behaviour pin in `memory-driver.test.ts` holds it),
// while `IDataDriver.update()` declared `Promise<Record<string, unknown>>` —
// a contract violated by 4 of 6 shipped drivers and never seen by `tsc`,
// because the driver's return type was INFERRED through the backing store's
// `any[]` rows: the union collapsed to `any`, the published `.d.ts` read
// `Promise<any>`, and no caller was ever asked to narrow. The maintainer's
// ruling (2026-09-01) declares the arm — `Promise<Record<string, unknown> |
// null>`, the shape `findOne` already carries — and un-masks the driver.
//
// This file pins BOTH halves at the type level, inside the package's tsc
// program (`tsconfig.json` selects `src/**/*`, tests included):
//
// 1. the CONTRACT: `IDataDriver.update()` resolves to
// `Record<string, unknown> | null` — read through `@objectstack/spec`'s
// built `.d.ts`, so reverting the declaration alone reds this file;
// 2. the DRIVER: `InMemoryDriver.update()` / `upsert()` are not `any` and
// resolve to exactly the contract's types — reverting the driver's
// explicit annotations alone reds this file too (the `any` mask
// returns and `IsAny` flips).
//
// The `satisfies`-free typed-const form is the one the sibling drivers use
// (`turso-driver-options-door.test.ts`, `sql-driver-distinct-filter-
// narrowing.test.ts`); the runtime cases below make the consts observable so
// the file is a test and not a declaration.

import { describe, it, expect } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';
import type { IDataDriver } from '@objectstack/spec/contracts';

/** `any` defeats ordinary assignability checks; this is the standard detector. */
type IsAny<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>;
type MemoryUpdate = Awaited<ReturnType<InMemoryDriver['update']>>;
type MemoryUpsert = Awaited<ReturnType<InMemoryDriver['upsert']>>;

// 1. The contract declares the not-found arm.
const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryUpdateIsAny: IsAny<MemoryUpdate> = false;
const memoryUpdateIsContract: Equals<MemoryUpdate, Record<string, unknown> | null> = true;
const memoryUpsertIsAny: IsAny<MemoryUpsert> = false;
const memoryUpsertIsContract: Equals<MemoryUpsert, Record<string, unknown>> = true;

describe('InMemoryDriver.update()/upsert() declared return types (#13878)', () => {
it('pins the contract and the driver at the type level', () => {
expect([
contractUpdateDeclaresNull,
memoryUpdateIsAny,
memoryUpdateIsContract,
memoryUpsertIsAny,
memoryUpsertIsContract,
]).toEqual([true, false, true, false, true]);
});

it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => {
const driver = new InMemoryDriver();
await driver.connect();

const result = await driver.update('t', 'missing', { name: 'x' });
expect(result).toBeNull();

// The narrowing the declared type now demands of every caller: a field
// read is only reachable behind the `null` check.
const name = result === null ? 'absent' : result.name;
expect(name).toBe('absent');
});

it('upsert() over an existing id returns the merged record and never the not-found arm', async () => {
const driver = new InMemoryDriver();
await driver.connect();
await driver.create('t', { id: '1', name: 'before', keep: 'kept' });

const merged = await driver.upsert('t', { id: '1', name: 'after' });
expect(merged.id).toBe('1');
expect(merged.name).toBe('after');
expect(merged.keep).toBe('kept');
});
});
4 changes: 3 additions & 1 deletion packages/metadata/src/loaders/database-loader.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,7 +307,9 @@ export class DatabaseLoader implements MetadataLoader {
return this.driver!.create(table, data);
}

private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
// #13878); both callers here resolve the row first and discard the result.
private async _update(table: string, id: string, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.engine) {
// [#11231] The resolved id AFTER the spread, so it WINS — the same
// convention as `rest-server.ts`'s batch arm and `protocol.updateData`
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,8 +189,13 @@ export interface IDataDriver {
/**
* Update an existing record by ID.
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
* @returns The updated record, or `null` if no record with that id exists —
* the same "addressed one row, which may not exist" shape as {@link findOne},
* and the not-found vocabulary {@link delete} already carries (`false`). A
* driver configured to throw on missing records (`strictMode`) throws
* instead of returning `null`.
*/
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>>;
update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null>;

/**
* Upsert (Update or Insert) a record.
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/driver.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -504,12 +504,13 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({
* @param id - The unique identifier of the record.
* @param data - The fields to update.
* @param options - Driver options.
* @returns The updated record.
* @returns The updated record, or `null` if no record with that id exists
* (a driver configured to throw on missing records throws instead).
* MUST return `id` as string. MUST NOT return implementation details like `_id`.
*/
update: z.function()
.input(z.tuple([z.string(), z.string().or(z.number()), z.record(z.string(), z.unknown()), DriverOptionsSchema.optional()]))
.output(z.promise(z.record(z.string(), z.unknown())))
.output(z.promise(z.record(z.string(), z.unknown()).nullable()))
.describe('Update record'),

/**
Expand Down
Loading