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
35 changes: 13 additions & 22 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,19 @@ const buildQueryParams = (query: StackQuery): URLSearchParams => {

export class APIAdapter implements StackAdapter {
readonly capabilities: AdapterCapabilities;
readonly ownerEntityId: string;
readonly timezone: string;

private constructor(
private readonly baseUrl: string,
private readonly token: string | undefined,
private readonly config: Map<string, string>,
ownerEntityId: string,
timezone: string,
capabilities: AdapterCapabilities,
) {
this.capabilities = capabilities;
this.ownerEntityId = ownerEntityId;
this.timezone = timezone;
}

/**
Expand Down Expand Up @@ -208,13 +213,13 @@ export class APIAdapter implements StackAdapter {

const discovery = (await res.json()) as DiscoveryResponse;

const config = new Map<string, string>([
['entity_id', discovery.entityId],
['version', discovery.version],
]);
if (discovery.timezone) config.set('timezone', discovery.timezone);

return new APIAdapter(baseUrl, opts.token, config, discovery.capabilities);
return new APIAdapter(
baseUrl,
opts.token,
discovery.entityId,
discovery.timezone ?? 'UTC',
discovery.capabilities,
);
}

// -------------------------------------------------------
Expand Down Expand Up @@ -292,20 +297,6 @@ export class APIAdapter implements StackAdapter {
return res.json() as Promise<Record<string, unknown>>;
}

// -------------------------------------------------------
// Config
// -------------------------------------------------------

async getConfig(key: string): Promise<string | null> {
return this.config.get(key) ?? null;
}

async setConfig(_key: string, _value: string): Promise<void> {
throw new APIAdapterError(
'setConfig is not supported: server configuration is managed server-side',
);
}

// -------------------------------------------------------
// Records
// -------------------------------------------------------
Expand Down
52 changes: 15 additions & 37 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ describe('open', () => {
expect(adapter.capabilities.sortableFields).toEqual(['createdAt', 'updatedAt', 'version']);
});

test('populates ownerEntityId from discovery response', async () => {
const adapter = await openAdapter();
expect(adapter.ownerEntityId).toBe('entity-owner-123');
});

test('populates timezone from discovery response', async () => {
const adapter = await openAdapter();
expect(adapter.timezone).toBe('America/New_York');
});

test('defaults timezone to UTC when not in discovery response', async () => {
const adapter = await openAdapter({ ...DISCOVERY, timezone: undefined });
expect(adapter.timezone).toBe('UTC');
});

test('omits Authorization header when no token provided', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(DISCOVERY));
await APIAdapter.open({ url: BASE_URL });
Expand Down Expand Up @@ -136,43 +151,6 @@ describe('open', () => {
});
});

// -------------------------------------------------------
// getConfig
// -------------------------------------------------------

describe('getConfig', () => {
test('returns entity_id from discovery', async () => {
const adapter = await openAdapter();
expect(await adapter.getConfig('entity_id')).toBe('entity-owner-123');
});

test('returns timezone from discovery', async () => {
const adapter = await openAdapter();
expect(await adapter.getConfig('timezone')).toBe('America/New_York');
});

test('returns version from discovery', async () => {
const adapter = await openAdapter();
expect(await adapter.getConfig('version')).toBe('1.0');
});

test('returns null for unknown keys', async () => {
const adapter = await openAdapter();
expect(await adapter.getConfig('nonexistent')).toBeNull();
});
});

// -------------------------------------------------------
// setConfig
// -------------------------------------------------------

describe('setConfig', () => {
test('throws APIAdapterError — server owns its config', async () => {
const adapter = await openAdapter();
await expect(adapter.setConfig('timezone', 'UTC')).rejects.toThrow(APIAdapterError);
});
});

// -------------------------------------------------------
// createRecord
// -------------------------------------------------------
Expand Down
71 changes: 22 additions & 49 deletions packages/adapter-sqlite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
* File IDs are SHA-256 hashes of the content; the filesystem
* is the authoritative store — no separate DB table is needed.
*
* Stack config (timezone, entity_id, etc.) is stored in a
* `stack_config` key/value table.
* Stack config (ownerEntityId, timezone) is stored as a singleton
* _config@1 record with id='_config' in the records table.
*/

import initSqlJs from 'sql.js';
Expand Down Expand Up @@ -64,11 +64,6 @@ export type TokenInfo = {
// -------------------------------------------------------

const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS stack_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT;

CREATE TABLE IF NOT EXISTS records (
id TEXT PRIMARY KEY,
type_id TEXT NOT NULL,
Expand Down Expand Up @@ -299,7 +294,7 @@ const getSortColumn = (field: SortField): string =>
field === 'createdAt' ? 'created_at' : field === 'updatedAt' ? 'updated_at' : 'version';

const buildWhereClause = (query: StackQuery): { sql: string; params: unknown[] } => {
const conditions: string[] = [];
const conditions: string[] = ["r.id != '_config'"];
const params: unknown[] = [];
const f = query.filter ?? {};

Expand Down Expand Up @@ -460,6 +455,9 @@ export class SQLiteAdapter implements StackAdapter {
sortableFields: ['createdAt', 'updatedAt', 'version'],
};

ownerEntityId!: string;
timezone!: string;

private db!: Database;
private readonly attachmentsDir: string;

Expand All @@ -485,13 +483,15 @@ export class SQLiteAdapter implements StackAdapter {
const adapter = new SQLiteAdapter(SQL, opts.path);
adapter.db = new SQL.Database();
adapter.db.run(SCHEMA_SQL);
adapter.runMigrations();
mkdirSync(adapter.attachmentsDir, { recursive: true });
const now = Date.now();
adapter.db.run(
`INSERT INTO stack_config (key, value) VALUES
('entity_id', ?), ('timezone', ?), ('version', '1')`,
[opts.entityId, opts.timezone],
`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
VALUES ('_config', '_config@1', ?, ?, ?, 1)`,
[now, now, JSON.stringify({ entityId: opts.entityId, timezone: opts.timezone })],
);
adapter.ownerEntityId = opts.entityId;
adapter.timezone = opts.timezone;
adapter.persist();
return adapter;
}
Expand All @@ -511,33 +511,12 @@ export class SQLiteAdapter implements StackAdapter {
const adapter = new SQLiteAdapter(SQL, opts.path);
const fileBuffer = readFileSync(opts.path);
adapter.db = new SQL.Database(fileBuffer);
adapter.db.run(SCHEMA_SQL); // safe — all statements use CREATE IF NOT EXISTS
adapter.runMigrations();
adapter.db.run(SCHEMA_SQL);
mkdirSync(adapter.attachmentsDir, { recursive: true });
adapter.readConfig();
return adapter;
}

// -------------------------------------------------------
// Config
// -------------------------------------------------------

async getConfig(key: string): Promise<string | null> {
const stmt = this.db.prepare('SELECT value FROM stack_config WHERE key = ?');
stmt.bind([key]);
if (stmt.step()) {
const row = stmt.getAsObject();
stmt.free();
return row.value as string;
}
stmt.free();
return null;
}

async setConfig(key: string, value: string): Promise<void> {
this.db.run('INSERT OR REPLACE INTO stack_config (key, value) VALUES (?, ?)', [key, value]);
this.persist();
}

// -------------------------------------------------------
// Persistence
// -------------------------------------------------------
Expand All @@ -548,20 +527,14 @@ export class SQLiteAdapter implements StackAdapter {
writeFileSync(this.path, Buffer.from(data));
}

// -------------------------------------------------------
// Schema migrations
// -------------------------------------------------------

/**
* Runs after SCHEMA_SQL to handle databases created before breaking schema
* changes. Safe to call on both fresh and existing databases.
*/
private runMigrations(): void {
// The attachments table is obsolete — binary files are content-addressed on
// disk and the filesystem is the authoritative source of truth. Drop it if
// an older database still has it.
const cols = this.execQuery<{ name: string }>('PRAGMA table_info(attachments)');
if (cols.length) this.db.run('DROP TABLE attachments');
private readConfig(): void {
const rows = this.execQuery<{ content: string }>(
`SELECT content FROM records WHERE id = '_config'`,
);
if (!rows.length) throw new Error('Stack database is missing its config record.');
const content = JSON.parse(rows[0].content) as { entityId: string; timezone?: string };
this.ownerEntityId = content.entityId;
this.timezone = content.timezone ?? 'UTC';
}

// -------------------------------------------------------
Expand Down
41 changes: 10 additions & 31 deletions packages/adapter-sqlite/tests/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,14 @@ describe('initialize', () => {
expect(existsSync(join(testDir, 'attachments'))).toBe(true);
});

test('stores entity_id in config', async () => {
test('sets ownerEntityId', async () => {
const adapter = await initAdapter({ entityId: 'owner-abc' });
expect(await adapter.getConfig('entity_id')).toBe('owner-abc');
expect(adapter.ownerEntityId).toBe('owner-abc');
});

test('stores timezone in config', async () => {
test('sets timezone', async () => {
const adapter = await initAdapter({ timezone: 'Europe/London' });
expect(await adapter.getConfig('timezone')).toBe('Europe/London');
});

test('stores version in config', async () => {
const adapter = await initAdapter();
expect(await adapter.getConfig('version')).toBe('1');
expect(adapter.timezone).toBe('Europe/London');
});

test('throws if database already exists', async () => {
Expand All @@ -89,7 +84,7 @@ describe('open', () => {
test('opens an existing database', async () => {
await initAdapter();
const adapter = await SQLiteAdapter.open({ path: dbPath });
expect(await adapter.getConfig('entity_id')).toBe('entity-123');
expect(adapter.ownerEntityId).toBe('entity-123');
});

test('throws if database does not exist', async () => {
Expand All @@ -111,28 +106,12 @@ describe('open', () => {
});
});

// -------------------------------------------------------
// Config
// -------------------------------------------------------

describe('config', () => {
test('setConfig stores a value', async () => {
const adapter = await initAdapter();
await adapter.setConfig('custom_key', 'custom_value');
expect(await adapter.getConfig('custom_key')).toBe('custom_value');
});

test('setConfig overwrites existing value', async () => {
const adapter = await initAdapter({ timezone: 'UTC' });
await adapter.setConfig('timezone', 'Asia/Tokyo');
expect(await adapter.getConfig('timezone')).toBe('Asia/Tokyo');
});

test('getConfig returns null for missing key', async () => {
const adapter = await initAdapter();
expect(await adapter.getConfig('nonexistent')).toBeNull();
test('preserves ownerEntityId and timezone across reopen', async () => {
await initAdapter({ entityId: 'owner-abc', timezone: 'Europe/London' });
const adapter = await SQLiteAdapter.open({ path: dbPath });
expect(adapter.ownerEntityId).toBe('owner-abc');
expect(adapter.timezone).toBe('Europe/London');
});
});

// -------------------------------------------------------
// Types
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type {
GroupContent,
GrantAction,
GrantContent,
ConfigContent,
} from './types.js';

export { SYSTEM_TYPES } from './types.js';
Expand Down
30 changes: 17 additions & 13 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,31 +133,31 @@ export interface StackClient {
export class Stack implements StackClient {
private readonly migrations = new Map<TypeId, Migration>();

private constructor(
private readonly adapter: StackAdapter,
readonly ownerEntityId: string,
readonly timezone: string,
) {}
private constructor(private readonly adapter: StackAdapter) {}

/**
* Create a Stack instance. Reads ownerEntityId and timezone from the
* adapter's config — the adapter is the single source of truth for
* stack-level configuration.
* Create a Stack instance. Reads ownerEntityId and timezone from the adapter.
*/
static async create(adapter: StackAdapter): Promise<Stack> {
const entityId = await adapter.getConfig('entity_id');
if (!entityId) {
if (!adapter.ownerEntityId) {
throw new Error(
'Stack misconfiguration: adapter has no entity_id. ' +
'Stack misconfiguration: adapter has no ownerEntityId. ' +
'Initialise the adapter with an entityId before calling Stack.create().',
);
}
const timezone = (await adapter.getConfig('timezone')) ?? 'UTC';
const stack = new Stack(adapter, entityId, timezone);
const stack = new Stack(adapter);
await stack.seedSystemTypes();
return stack;
}

get ownerEntityId(): string {
return this.adapter.ownerEntityId;
}

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

get features(): StackFeatures {
return this.adapter.capabilities;
}
Expand Down Expand Up @@ -663,6 +663,10 @@ 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 },
});
await this.defineType(`${SYSTEM_TYPES.ENTITY}@1`, 'Entity', {
name: { kind: 'string', required: true },
handle: { kind: 'string' },
Expand Down
Loading
Loading