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
12 changes: 9 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}
Expand Down
6 changes: 4 additions & 2 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/adapter-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -146,7 +146,7 @@ export class LocalAdapter implements StackAdapter {
return this.record.ownerEntityId;
}

get timezone(): string {
get timezone(): string | undefined {
return this.record.timezone;
}

Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export class Stack implements StackClient {
return this.adapter.ownerEntityId;
}

get timezone(): string {
get timezone(): string | undefined {
return this.adapter.timezone;
}

Expand Down Expand Up @@ -1509,7 +1509,8 @@ export class Stack implements StackClient {
private async seedSystemTypes(): Promise<void> {
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 },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<StackRecord | null> {
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class MemoryAdapter implements StackAdapter {
};

readonly ownerEntityId: string;
readonly timezone: string;
readonly timezone: string | undefined;

readonly records = new Map<string, StackRecord>();
readonly order: string[] = [];
Expand All @@ -41,7 +41,7 @@ export class MemoryAdapter implements StackAdapter {

constructor({
ownerEntityId = '',
timezone = 'UTC',
timezone,
}: { ownerEntityId?: string; timezone?: string } = {}) {
this.ownerEntityId = ownerEntityId;
this.timezone = timezone;
Expand Down Expand Up @@ -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<string, unknown>)[key] === value),
entries.every(([key, value]) => {
const actual = (r.content as Record<string, unknown>)[key];
return value === null ? actual === null || actual === undefined : actual === value;
}),
);
}

Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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<StackRecord>;
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------
Expand Down Expand Up @@ -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}`,
Expand Down
30 changes: 29 additions & 1 deletion packages/core/tests/scoped-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
30 changes: 28 additions & 2 deletions packages/core/tests/stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down Expand Up @@ -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
// -------------------------------------------------------
Expand Down
Loading
Loading