diff --git a/docs/spec.md b/docs/spec.md index 9d217bb..71caaaf 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -12,14 +12,14 @@ A **Stack** is a structured, portable personal or organizational data store. It ## Stack initialization -A Stack is created via an async factory that reads identity and timezone from the adapter. +A Stack is created via an async factory that reads identity (and, optionally, timezone) from the adapter. ```ts // First run — create a new database with initial config const adapter = await LocalAdapter.initialize({ path: './my-stack.db', entityId: 'abc123', // required — owner entity ID - timezone: 'America/New_York', // required — IANA timezone string + timezone: 'America/New_York', // optional — IANA timezone string, passthrough metadata }); // Subsequent runs — open an existing database @@ -28,7 +28,7 @@ const adapter = await LocalAdapter.open({ path: './my-stack.db' }); // Always the same — reads identity and timezone from the adapter const stack = await Stack.create(adapter); stack.ownerEntityId; // from adapter.ownerEntityId -stack.timezone; // from adapter.timezone +stack.timezone; // from adapter.timezone — string | undefined ``` `LocalAdapter.initialize()` fails if the file already exists. `LocalAdapter.open()` fails if the file does not exist. This makes the distinction explicit and prevents silent config divergence. @@ -37,6 +37,8 @@ Plugin and extension code that doesn't need to know the underlying backend shoul **Stack identity** (`ownerEntityId`, `timezone`) is stored as a singleton `_config@1` record in the records table. Adapters expose these values as typed readonly properties (`adapter.ownerEntityId`, `adapter.timezone`) rather than as a generic key/value store. +**`timezone` is optional, passthrough app metadata — nothing in core reads it for behavior** (#69). It's a presentation concern (an app might use it to format dates for display) that happens to live at the data layer because `_config` is the natural place to store one fact per stack. There is no default: an absent `timezone` stays `undefined` end to end (`ConfigContent.timezone`, `adapter.timezone`, discovery's `timezone` field) rather than being defaulted to `'UTC'` — a default would assert knowledge the stack was never actually given, and a stack initialized without a timezone in one place shouldn't silently acquire a wrong one somewhere downstream. Apps that want a display default apply it themselves, explicitly, at the point they format something. + **For the API adapter**, identity values are sourced from the discovery endpoint (`GET /.well-known/stack`) when the adapter is opened and cached for the session as adapter properties. **`_config` is protected** (#67), by `Stack` itself rather than any one adapter — the same layering as schema validation, so every adapter and `ScopedStack` (which delegates to `Stack`) inherit it automatically: @@ -246,6 +248,8 @@ type StackType = { **Array and object fields** are schema-validated on write but opaque to the query engine in v1 — only top-level scalar fields support exact-match content filtering in queries. +**`date` fields validate against an ISO 8601 shape, not bare `Date.parse`** (#69) — `YYYY-MM-DD`, optionally extended with `THH:mm:ss`, optional fractional seconds, and an optional `Z`/numeric-offset suffix. A regex pins the shape; `Date.parse` then runs as a calendar sanity check on top of it (catches e.g. an invalid month). This is stricter than `Date.parse` alone, which also accepts engine-dependent, non-ISO formats ("March 1 2020", "3/1/2020") that the "Expected ISO 8601 date string" validation error already claimed to reject — those formats let cross-runtime stacks disagree about what's valid and produce non-canonical stored values. + **`file-ref` fields are real references, not just strings that look like fileIds.** A `file-ref` value must be a well-formed fileId (SHA-256 hex) — validated at write time, though referential existence is not (the same stance as `record-ref`; upload-before-associate flows make strictness hostile). What `file-ref` buys over a plain `string` field holding the same value: the [`attachmentFileId` query filter](#queries), [`deleteAttachment()`'s reference check](#attachments), and attachment-access conveyance under `ScopedStack` all treat a top-level `file-ref` field as a real reference to the file, the same way an `attachment` Association is. An app that stores a fileId in a plain `string` field keeps working, but gets **none** of that — no delete protection, no access conveyance, no garbage-collection protection. Only top-level scalar `file-ref` fields are indexed this way (matching the content-filtering limit above); a `file-ref` nested in an array or object is validated but not indexed as a reference. **Type identity:** Two Types are the same if their `id` matches (including version). Two stacks running the same app will have the same Type IDs and can rely on that for interop. @@ -729,6 +733,8 @@ type DateRange = { }; ``` +**A `content` filter value of `null` means "the field is absent or stored as `null`"** (#69) — not "match nothing." Plain equality (SQL `= NULL`, or JS `===` against a possibly-absent key) is never true for a missing field, which used to make `{ content: { x: null } }` silently return an empty result — the same silent-subset shape as #56's wire-level gaps, just one layer down. Every adapter, including `MemoryAdapter`, implements `IS NULL` / missing-path semantics for a `null` filter value: it matches a record whose content omits the key entirely and one that stores the key with a literal `null` value alike, since from the caller's side both mean "no value here." + `baseId` matches every version of a type family — resolved against registered Types (via `listTypes()`), not string-parsed from `typeId`, so it works regardless of which versions happen to exist. This is what fixes `typeId`-filtered queries silently missing not-yet-migrated older-version records under [explicit, owner-driven migration](#type-migrations): filter by `baseId` to see the whole family, or `typeId` for an exact version. Given both, they intersect. `Stack.query()` resolves `baseId` client-side before dispatching to the adapter — adapters and the wire protocol only ever see a concrete `typeId` set, so no adapter needs its own `baseId` concept. An unknown `baseId` returns an empty result set rather than throwing. By default, `query()` (like `get()`) returns Records exactly as stored — see [presentAt: 'latest'](#type-migrations) to migrate results in memory instead. diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 441cd5e..08366a2 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -199,13 +199,13 @@ const buildQueryParams = (query: StackQuery): URLSearchParams => { export class APIAdapter implements StackAdapter { readonly capabilities: AdapterCapabilities; readonly ownerEntityId: string; - readonly timezone: string; + readonly timezone: string | undefined; private constructor( private readonly baseUrl: string, private readonly token: string | undefined, ownerEntityId: string, - timezone: string, + timezone: string | undefined, capabilities: AdapterCapabilities, ) { this.capabilities = capabilities; @@ -244,7 +244,9 @@ export class APIAdapter implements StackAdapter { baseUrl, opts.token, discovery.entityId, - discovery.timezone ?? 'UTC', + // Passthrough metadata only — no 'UTC' default, which would claim + // knowledge the discovery response didn't actually provide (#69). + discovery.timezone, discovery.capabilities, ); } diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 08e0abc..3f8634d 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -132,9 +132,11 @@ describe('open', () => { expect(adapter.timezone).toBe('America/New_York'); }); - test('defaults timezone to UTC when not in discovery response', async () => { + // #69: timezone is passthrough metadata only — no 'UTC' default, which + // would claim knowledge the discovery response didn't actually provide. + test('timezone is undefined when not in discovery response — no default', async () => { const adapter = await openAdapter({ ...DISCOVERY, timezone: undefined }); - expect(adapter.timezone).toBe('UTC'); + expect(adapter.timezone).toBeUndefined(); }); test('omits Authorization header when no token provided', async () => { diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts index 08bac58..854a5bd 100644 --- a/packages/adapter-local/src/index.ts +++ b/packages/adapter-local/src/index.ts @@ -58,8 +58,8 @@ export { DiskBlobAdapter } from '@haverstack/blob-adapter-disk'; export type LocalInitializeOptions = { /** Absolute path to the .db file. Must not already exist. */ path: string; - /** IANA timezone string e.g. "America/New_York". */ - timezone: string; + /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */ + timezone?: string; /** Entity ID of the stack owner. */ entityId: string; /** Bypass the storage-ownership lock check. See LocalOpenOptions.force. */ @@ -146,7 +146,7 @@ export class LocalAdapter implements StackAdapter { return this.record.ownerEntityId; } - get timezone(): string { + get timezone(): string | undefined { return this.record.timezone; } diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 471d430..48dec57 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -427,7 +427,7 @@ export class Stack implements StackClient { return this.adapter.ownerEntityId; } - get timezone(): string { + get timezone(): string | undefined { return this.adapter.timezone; } @@ -1509,7 +1509,8 @@ export class Stack implements StackClient { private async seedSystemTypes(): Promise { await this.defineType(`${SYSTEM_TYPES.CONFIG}@1`, 'Config', { entityId: { kind: 'string', required: true }, - timezone: { kind: 'string', required: true }, + // Optional passthrough app metadata — see ConfigContent.timezone (#69). + timezone: { kind: 'string' }, }); await this.defineType(`${SYSTEM_TYPES.ENTITY}@1`, 'Entity', { name: { kind: 'string', required: true }, @@ -1864,7 +1865,12 @@ export class ScopedStack implements StackClient { * Create a new record on behalf of the authenticated requester. * Requires either an entity-specific _grant or a default _grant for * the target type. Anonymous requesters (null entityId) are always denied. - * The created record's entityId is always set to the requester. + * The created record's entityId is set to the requester — unless the + * requester *is* the owner, in which case entityId is omitted, matching + * the spec's "owner-created records carry no entityId" invariant (#69). + * Without this, the owner writing through asEntity(ownerEntityId) would + * produce a differently-shaped record than Stack.create() for the exact + * same author. * * A client-supplied `opts.id` gets the same format validation as * Stack.create() plus a timestamp-skew check — the requester here is an @@ -1910,7 +1916,11 @@ export class ScopedStack implements StackClient { baseIdOf(typeId) === SYSTEM_TYPES.GROUP ? { ...opts, associations: stampGroupAdmin(opts.associations, requester) } : opts; - return this.stack.create(typeId, content, { ...createOpts, entityId: requester }); + const isOwner = requester === this.stack.ownerEntityId; + return this.stack.create(typeId, content, { + ...createOpts, + entityId: isOwner ? undefined : requester, + }); } async get(id: string, opts: GetRecordOptions = {}): Promise { diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index ac361ba..1978386 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -31,7 +31,7 @@ export class MemoryAdapter implements StackAdapter { }; readonly ownerEntityId: string; - readonly timezone: string; + readonly timezone: string | undefined; readonly records = new Map(); readonly order: string[] = []; @@ -41,7 +41,7 @@ export class MemoryAdapter implements StackAdapter { constructor({ ownerEntityId = '', - timezone = 'UTC', + timezone, }: { ownerEntityId?: string; timezone?: string } = {}) { this.ownerEntityId = ownerEntityId; this.timezone = timezone; @@ -185,10 +185,16 @@ export class MemoryAdapter implements StackAdapter { ), ); } + // A `null` filter value means "field absent or null" — not "match + // nothing" (#69). Plain `===` would miss an absent field, since + // `undefined === null` is false; treat both as satisfying a null filter. if (f.content) { const entries = Object.entries(f.content); results = results.filter((r) => - entries.every(([key, value]) => (r.content as Record)[key] === value), + entries.every(([key, value]) => { + const actual = (r.content as Record)[key]; + return value === null ? actual === null || actual === undefined : actual === value; + }), ); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f743263..1b47898 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -228,8 +228,13 @@ export type AttachmentContent = { export type ConfigContent = { /** Entity ID of the stack owner. */ entityId: string; - /** IANA timezone string e.g. "America/New_York". */ - timezone: string; + /** + * IANA timezone string e.g. "America/New_York". Optional passthrough app + * metadata — nothing in core reads it for behavior. Absent means unset; + * there is no default, since defaulting to a real timezone would claim + * knowledge the stack doesn't have (#69). + */ + timezone?: string; }; /** Reserved system type IDs */ @@ -381,8 +386,13 @@ export interface StackRecordAdapter { /** Entity ID of the stack owner. Set during adapter initialization. */ readonly ownerEntityId: string; - /** IANA timezone string for this stack e.g. "America/New_York". */ - readonly timezone: string; + /** + * IANA timezone string for this stack e.g. "America/New_York", or + * undefined if never set. Passthrough app metadata — no core behavior + * reads it, and there is no 'UTC' default, since defaulting would claim + * knowledge the stack doesn't have (#69). + */ + readonly timezone: string | undefined; // Records createRecord(record: StackRecord): Promise; diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index eb3c565..75eb282 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -15,6 +15,18 @@ const MAX_VALIDATION_DEPTH = 32; /** fileId format: SHA-256 hex, lowercase — matches blob-adapter-disk's assertFileId(). */ const FILE_ID_RE = /^[0-9a-f]{64}$/; +/** + * ISO 8601 date or date-time shape — date-only ("2024-01-15") or full + * date-time with optional fractional seconds and an optional "Z"/offset + * suffix ("2024-01-15T14:30:00.123+05:00"). Bare `Date.parse` accepts far + * more than this (engine-dependent formats like "March 1 2020" or + * "3/1/2020"), which contradicts the "Expected ISO 8601 date string" error + * message and lets cross-runtime stacks disagree about what's valid (#69). + * `Date.parse` still runs afterward as a calendar sanity check (e.g. + * rejects month 13) — the regex only pins the shape. + */ +const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/; + // ------------------------------------------------------- // Validation errors // ------------------------------------------------------- @@ -79,7 +91,11 @@ const validateField = ( // Scalar validation if (def.kind === 'date') { - if (typeof value !== 'string' || isNaN(Date.parse(value as string))) { + if ( + typeof value !== 'string' || + !ISO_8601_RE.test(value) || + isNaN(Date.parse(value as string)) + ) { errors.push({ path, message: `Expected ISO 8601 date string, got ${typeof value}`, diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 0728118..404fea4 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -385,7 +385,35 @@ describe('ScopedStack.create', () => { test('owner can always create records via ScopedStack', async () => { const record = await stack.asEntity(OWNER).create(COMMENT, { text: 'hello' }); expect(record.typeId).toBe(COMMENT); - expect(record.entityId).toBe(OWNER); + }); + + // #69: the spec says owner-created records carry no entityId — that must + // hold whether the owner writes through Stack.create() directly or + // through ScopedStack.asEntity(ownerEntityId). Before this fix, only the + // former was true. + test('owner writing through asEntity(ownerEntityId) omits entityId, matching Stack.create()', async () => { + const record = await stack.asEntity(OWNER).create(COMMENT, { text: 'hello' }); + expect(record.entityId).toBeUndefined(); + }); + + test('a non-owner entity still gets entityId stamped as the author', async () => { + await stack.grant(MEMBER, [{ actions: ['create'], typeId: COMMENT }]); + const record = await stack.asEntity(MEMBER).create(COMMENT, { text: 'hello' }); + expect(record.entityId).toBe(MEMBER); + }); + + // Owner bypasses grants entirely (checkAccess's owner shortcut), so an + // owner-authored record carrying no entityId can never accidentally + // satisfy a *different* requester's -own grant check — pinning the claim + // from #69 that this normalization doesn't regress -own semantics. + test('a stranger with a -own grant cannot use it against an owner-authored record', async () => { + const ownerRecord = await stack.asEntity(OWNER).create(COMMENT, { text: 'hello' }); + await stack.grant(STRANGER, [{ actions: ['read-own', 'update-own'], typeId: COMMENT }]); + const view = stack.asEntity(STRANGER); + await expect(view.get(ownerRecord.id)).rejects.toThrow(StackPermissionError); + await expect(view.update(ownerRecord.id, { text: 'hijacked' })).rejects.toThrow( + StackPermissionError, + ); }); test('anonymous requester cannot create records', async () => { diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index a8febe6..ddd9792 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -57,10 +57,13 @@ describe('Stack.create', () => { ); }); - test('timezone defaults to UTC when not specified', async () => { + // #69: timezone is optional passthrough metadata, nothing more — no + // default, since defaulting to a real timezone would claim knowledge the + // stack doesn't have. + test('timezone is undefined when not specified — no default', async () => { const adapter = new MemoryAdapter({ ownerEntityId: 'entity-without-timezone' }); const s = await Stack.create(adapter); - expect(s.timezone).toBe('UTC'); + expect(s.timezone).toBeUndefined(); }); }); @@ -370,6 +373,29 @@ describe('type cache', () => { }); }); +// ------------------------------------------------------- +// content filter null semantics (#69) — MemoryAdapter's own +// implementation, mirroring the SQL adapters' shared buildWhereClause fix. +// ------------------------------------------------------- + +describe('query — content filter null semantics', () => { + test('a null content filter matches records where the field is absent', async () => { + await stack.create(NOTE_V1, { text: 'no priority set' }); + await stack.create(NOTE_V1, { text: 'has one', priority: 1 }); + const result = await stack.query({ filter: { content: { priority: null } } }); + expect(result.records).toHaveLength(1); + expect(result.records[0].content.text).toBe('no priority set'); + }); + + test('a null content filter matches records where the field is stored as null', async () => { + await stack.create(NOTE_V1, { text: 'explicit null', priority: null }); + await stack.create(NOTE_V1, { text: 'has one', priority: 1 }); + const result = await stack.query({ filter: { content: { priority: null } } }); + expect(result.records).toHaveLength(1); + expect(result.records[0].content.text).toBe('explicit null'); + }); +}); + // ------------------------------------------------------- // update — merge patch // ------------------------------------------------------- diff --git a/packages/core/tests/validate.test.ts b/packages/core/tests/validate.test.ts index 0132b8a..ba940ae 100644 --- a/packages/core/tests/validate.test.ts +++ b/packages/core/tests/validate.test.ts @@ -100,6 +100,16 @@ describe('scalar field validation', () => { expect(errorsFor({ dueAt: '2024-01-15T14:30:00Z' }, schema)).toEqual([]); }); + test('date field accepts ISO 8601 with fractional seconds and a numeric offset', () => { + const schema: TypeSchema = { dueAt: { kind: 'date', required: true } }; + expect(errorsFor({ dueAt: '2024-01-15T14:30:00.123+05:00' }, schema)).toEqual([]); + }); + + test('date field accepts a date-only ISO 8601 string', () => { + const schema: TypeSchema = { dueAt: { kind: 'date', required: true } }; + expect(errorsFor({ dueAt: '2024-01-15' }, schema)).toEqual([]); + }); + test('date field rejects non-date string', () => { const schema: TypeSchema = { dueAt: { kind: 'date', required: true } }; expect(paths({ dueAt: 'not-a-date' }, schema)).toContain('dueAt'); @@ -110,6 +120,23 @@ describe('scalar field validation', () => { expect(paths({ dueAt: 1704067200000 }, schema)).toContain('dueAt'); }); + // #69: bare Date.parse accepted these — engine-dependent, non-ISO formats + // that contradicted the "Expected ISO 8601 date string" error message. + test('date field rejects a long-form date previously accepted by bare Date.parse', () => { + const schema: TypeSchema = { dueAt: { kind: 'date', required: true } }; + expect(paths({ dueAt: 'March 1 2020' }, schema)).toContain('dueAt'); + }); + + test('date field rejects a slash-formatted date previously accepted by bare Date.parse', () => { + const schema: TypeSchema = { dueAt: { kind: 'date', required: true } }; + expect(paths({ dueAt: '3/1/2020' }, schema)).toContain('dueAt'); + }); + + test('date field rejects an ISO-shaped string with an invalid month', () => { + const schema: TypeSchema = { dueAt: { kind: 'date', required: true } }; + expect(paths({ dueAt: '2024-13-01' }, schema)).toContain('dueAt'); + }); + test('multiple errors are all collected', () => { const schema: TypeSchema = { name: { kind: 'string', required: true }, diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index 55d690b..2c6f274 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -72,8 +72,8 @@ import { NativeSqliteExecutor } from './executor.js'; export type NativeRecordInitializeOptions = { /** Absolute path to the .db file. Must not already exist. */ path: string; - /** IANA timezone string e.g. "America/New_York". */ - timezone: string; + /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */ + timezone?: string; /** Entity ID of the stack owner. */ entityId: string; /** Bypass the storage-ownership lock check. See NativeRecordOpenOptions.force. */ @@ -130,7 +130,7 @@ export class NativeSQLiteRecordAdapter implements StackRecordAdapter { }; ownerEntityId!: string; - timezone!: string; + timezone: string | undefined; private db!: DatabaseSync; private record!: SharedSqlRecordLogic; diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index 434f223..5dccf24 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -598,6 +598,29 @@ describe('records — queries', () => { expect(result.records[0].id).toBe('r1'); }); + // #69: a null content filter means "field absent or null," never "match + // nothing" — plain SQL `= NULL` is always false, which used to make this + // silently return zero records with no signal. + test('content filter with a null value matches records where the field is absent', async () => { + const adapter = await initAdapter(); + await adapter.createRecord(makeRecord({ id: 'r1', content: { text: 'no priority set' } })); + await adapter.createRecord(makeRecord({ id: 'r2', content: { text: 'has one', priority: 1 } })); + const result = await adapter.queryRecords({ filter: { content: { priority: null } } }); + expect(result.records.length).toBe(1); + expect(result.records[0].id).toBe('r1'); + }); + + test('content filter with a null value matches records where the field is stored as null', async () => { + const adapter = await initAdapter(); + await adapter.createRecord( + makeRecord({ id: 'r1', content: { text: 'explicit null', priority: null } }), + ); + await adapter.createRecord(makeRecord({ id: 'r2', content: { text: 'has one', priority: 1 } })); + const result = await adapter.queryRecords({ filter: { content: { priority: null } } }); + expect(result.records.length).toBe(1); + expect(result.records[0].id).toBe('r1'); + }); + test('full-text search (FTS5)', async () => { const adapter = await initAdapter(); await adapter.createRecord(makeRecord({ id: 'r1', content: { text: 'SQLite is great' } })); diff --git a/packages/record-adapter-sqljs/src/index.ts b/packages/record-adapter-sqljs/src/index.ts index f55562a..4432b5d 100644 --- a/packages/record-adapter-sqljs/src/index.ts +++ b/packages/record-adapter-sqljs/src/index.ts @@ -67,8 +67,8 @@ import type { export type PersistFn = (bytes: Uint8Array) => Promise; export type SQLiteRecordInitializeOptions = { - /** IANA timezone string e.g. "America/New_York". */ - timezone: string; + /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */ + timezone?: string; /** Entity ID of the stack owner. */ entityId: string; /** Called after every write. Omit for a purely in-memory, non-persisted database. */ @@ -137,7 +137,7 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { }; ownerEntityId!: string; - timezone!: string; + timezone: string | undefined; private db!: Database; private exec!: SqlExecutor; diff --git a/packages/record-adapter-sqljs/tests/record.test.ts b/packages/record-adapter-sqljs/tests/record.test.ts index 07a5a3e..a524b6d 100644 --- a/packages/record-adapter-sqljs/tests/record.test.ts +++ b/packages/record-adapter-sqljs/tests/record.test.ts @@ -558,6 +558,29 @@ describe('records — queries', () => { expect(result.records[0].id).toBe('r1'); }); + // #69: a null content filter means "field absent or null," never "match + // nothing" — plain SQL `= NULL` is always false, which used to make this + // silently return zero records with no signal. + test('content filter with a null value matches records where the field is absent', async () => { + const adapter = await initAdapter(); + await adapter.createRecord(makeRecord({ id: 'r1', content: { text: 'no priority set' } })); + await adapter.createRecord(makeRecord({ id: 'r2', content: { text: 'has one', priority: 1 } })); + const result = await adapter.queryRecords({ filter: { content: { priority: null } } }); + expect(result.records.length).toBe(1); + expect(result.records[0].id).toBe('r1'); + }); + + test('content filter with a null value matches records where the field is stored as null', async () => { + const adapter = await initAdapter(); + await adapter.createRecord( + makeRecord({ id: 'r1', content: { text: 'explicit null', priority: null } }), + ); + await adapter.createRecord(makeRecord({ id: 'r2', content: { text: 'has one', priority: 1 } })); + const result = await adapter.queryRecords({ filter: { content: { priority: null } } }); + expect(result.records.length).toBe(1); + expect(result.records[0].id).toBe('r1'); + }); + test('full-text search', async () => { const adapter = await initAdapter(); await adapter.createRecord(makeRecord({ id: 'r1', content: { text: 'SQLite is great' } })); diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index df128d6..006e376 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -1,9 +1,14 @@ import type { SqlExecutor } from './executor.js'; -export type StackConfig = { entityId: string; timezone: string }; +/** timezone is optional passthrough app metadata — no default (#69). */ +export type StackConfig = { entityId: string; timezone: string | undefined }; /** Inserts the singleton _config@1 record. Only valid on a freshly-created database. */ -export const insertConfigRecord = (exec: SqlExecutor, entityId: string, timezone: string): void => { +export const insertConfigRecord = ( + exec: SqlExecutor, + entityId: string, + timezone: string | undefined, +): void => { const now = Date.now(); exec.run( `INSERT INTO records (id, type_id, created_at, updated_at, content, version) @@ -17,5 +22,5 @@ export const readStackConfig = (exec: SqlExecutor): StackConfig => { const row = exec.get<{ content: string }>(`SELECT content FROM records WHERE id = '_config'`); if (!row) throw new Error('Stack database is missing its config record.'); const content = JSON.parse(row.content) as { entityId: string; timezone?: string }; - return { entityId: content.entityId, timezone: content.timezone ?? 'UTC' }; + return { entityId: content.entityId, timezone: content.timezone }; }; diff --git a/packages/sqlite-shared/src/query.ts b/packages/sqlite-shared/src/query.ts index 3a73cde..0eb8c89 100644 --- a/packages/sqlite-shared/src/query.ts +++ b/packages/sqlite-shared/src/query.ts @@ -110,11 +110,21 @@ export const buildWhereClause = ( if (f.relatedTo.label) params.push(f.relatedTo.label); } - // Content field filters (top-level scalar exact match) + // Content field filters (top-level scalar exact match). A `null` value + // means "field absent or null" (IS NULL / missing-path semantics), not + // "match nothing" — plain SQL `= NULL` is never true, which would make + // `{ content: { x: null } }` silently return an empty result with no + // signal (#69). json_extract() already returns SQL NULL for both a + // missing path and a stored JSON null, so IS NULL captures both. if (f.content) { for (const [key, value] of Object.entries(f.content)) { - conditions.push(`json_extract(r.content, ?) = ?`); - params.push(`$.${key}`, value); + if (value === null) { + conditions.push(`json_extract(r.content, ?) IS NULL`); + params.push(`$.${key}`); + } else { + conditions.push(`json_extract(r.content, ?) = ?`); + params.push(`$.${key}`, value); + } } }