diff --git a/docs/development/planning/theorydb-10of10-roadmap.md b/docs/development/planning/theorydb-10of10-roadmap.md index 0e33a71..f90a626 100644 --- a/docs/development/planning/theorydb-10of10-roadmap.md +++ b/docs/development/planning/theorydb-10of10-roadmap.md @@ -148,7 +148,7 @@ Guardrails (no denominator games): **Implementation (in repo)** - Workflow: `.github/workflows/quality-gates.yml` runs `make rubric` on PRs to `premain` (and on pushes to `premain`). -- Tooling pins: `golangci-lint@v2.5.0`, `govulncheck@v1.1.4`, `gosec@v2.22.11` (plus `go.mod` toolchain `go1.25.6` via `go-version-file`). +- Tooling pins: `golangci-lint@v2.5.0`, `govulncheck@v1.1.4`, `gosec@v2.22.11` (plus `go.mod` toolchain `go1.25.7` via `go-version-file`). - Integration infra pin: DynamoDB Local uses `amazon/dynamodb-local:3.1.0` (via `docker-compose.yml` and `DYNAMODB_LOCAL_IMAGE`). --- diff --git a/docs/facetheory/isr-cache-schema.md b/docs/facetheory/isr-cache-schema.md index 25ce9b1..473f330 100644 --- a/docs/facetheory/isr-cache-schema.md +++ b/docs/facetheory/isr-cache-schema.md @@ -81,7 +81,13 @@ Lock rule (correctness boundary): - write new metadata + release lease as one atomic step - pointer-swap designs where you write a new version item and then update the “current” pointer -(See `docs/facetheory/isr-transaction-recipes.md` once FT-T2 lands.) +(See `docs/facetheory/isr-transaction-recipes.md`.) + +## Environment variables (FaceTheory ISR) + +- `FACETHEORY_CACHE_TABLE_NAME` is the canonical env var used by FaceTheory ISR cache metadata/lease models. +- If you are wiring this table via **AppTheory**, some constructs may provide `APPTHEORY_CACHE_TABLE_NAME` (and/or other + aliases). Ensure `FACETHEORY_CACHE_TABLE_NAME` is set so FaceTheory docs and examples work without guesswork. ## Runnable model definitions diff --git a/docs/facetheory/isr-transaction-recipes.md b/docs/facetheory/isr-transaction-recipes.md index f509cd6..e4d8f4a 100644 --- a/docs/facetheory/isr-transaction-recipes.md +++ b/docs/facetheory/isr-transaction-recipes.md @@ -22,9 +22,11 @@ Use this when you only need one metadata record per cache key. 1. Acquire the lease (`LOCK`) for `pk`. 2. Regenerate the body and write it to S3. 3. Publish metadata and release the lock with a single DynamoDB transaction: - - `ConditionCheck` the lease row (`lease_token` matches AND `lease_expires_at > now`) - `Put` the metadata row (`META`) with the new pointer (`s3_key`), timestamps, etag, ttl - - `Delete` the lease row (optionally conditioned on `lease_token`) + - `Delete` the lease row (`LOCK`) with a condition expression (`lease_token` matches AND `lease_expires_at > now`) + +Note: DynamoDB transactions cannot include multiple operations on the same item. Prefer a conditional `Delete`/`Update` +on the lease row over a separate `ConditionCheck` + `Delete` on the same lease key. ### Why the transaction matters @@ -46,10 +48,9 @@ Recommended item roles (same `pk`): Transaction sketch: -1. `ConditionCheck` the lease row (token + not expired). -2. `Put` the new version row (`VER#...`) guarded with `attribute_not_exists(pk)` to avoid duplicate IDs. -3. `Update` the pointer row (`META`) to set `current_sk` to the new version (optionally guard with optimistic `version`). -4. `Delete` the lease row (optionally conditioned on token). +1. `Put` the new version row (`VER#...`) guarded with `attribute_not_exists(pk)` to avoid duplicate IDs. +2. `Update` the pointer row (`META`) to set `current_sk` to the new version (optionally guard with optimistic `version`). +3. `Delete` the lease row (`LOCK`) with a condition expression (token + not expired). ## Stale-writer protection options @@ -96,16 +97,13 @@ metaItem := &models.FaceTheoryCacheMetadata{ } err := db.TransactWrite(ctx, func(tx core.TransactionBuilder) error { - tx.ConditionCheck( + tx.Put(metaItem) + + tx.Delete( leaseItem, tabletheory.Condition("lease_token", "=", leaseToken), tabletheory.Condition("lease_expires_at", ">", nowUnix), ) - - tx.Put(metaItem) - - // Optional: make the delete conditional on the same token. - tx.Delete(leaseItem, tabletheory.Condition("lease_token", "=", leaseToken)) return nil }) ``` @@ -114,17 +112,6 @@ err := db.TransactWrite(ctx, func(tx core.TransactionBuilder) error { ```ts await client.transactWrite([ - { - kind: 'condition', - model: 'FaceTheoryCacheLease', - key: { pk, sk: 'LOCK' }, - conditionExpression: '#tok = :tok AND #exp > :now', - expressionAttributeNames: { '#tok': 'lease_token', '#exp': 'lease_expires_at' }, - expressionAttributeValues: { - ':tok': { S: leaseToken }, - ':now': { N: String(nowUnix) }, - }, - }, { kind: 'put', model: 'FaceTheoryCacheMetadata', @@ -134,22 +121,56 @@ await client.transactWrite([ kind: 'delete', model: 'FaceTheoryCacheLease', key: { pk, sk: 'LOCK' }, + conditionExpression: '#tok = :tok AND #exp > :now', + expressionAttributeNames: { '#tok': 'lease_token', '#exp': 'lease_expires_at' }, + expressionAttributeValues: { + ':tok': { S: leaseToken }, + ':now': { N: String(nowUnix) }, + }, }, ]); ``` +### TypeScript (TableTheory helper: `FaceTheoryIsrMetaStore`) + +TableTheory exports a small helper that implements the FaceTheory ISR metadata + lease operations using: + +- `LeaseManager` for acquiring/releasing `LOCK` rows +- `TheorydbClient.transactWrite()` for atomic “publish META + release LOCK” (Recipe A) + +```ts +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { createFaceTheoryIsrMetaStore } from '@theory-cloud/tabletheory-ts'; + +const ddb = new DynamoDBClient({ region: process.env.AWS_REGION ?? 'us-east-1' }); +const isr = createFaceTheoryIsrMetaStore({ + ddb, + tableName: process.env.FACETHEORY_CACHE_TABLE_NAME!, +}); + +const lease = await isr.tryAcquireLease({ + cacheKey, + leaseOwner: 'my-app-instance', // optional (for app logs) + leaseDurationMs: 30_000, +}); +if (!lease) return; // another contender is regenerating + +await isr.commitGeneration({ + cacheKey, + leaseOwner: 'my-app-instance', // optional (for app logs) + leaseToken: lease.leaseToken, + htmlPointer: s3Key, + generatedAtMs: Date.now(), + revalidateSeconds: 60, + etag, +}); +``` + ### Python (`Table.transact_write`) ```py table.transact_write( [ - TransactConditionCheck( - pk=pk, - sk="LOCK", - condition_expression="#tok = :tok AND #exp > :now", - expression_attribute_names={"#tok": "lease_token", "#exp": "lease_expires_at"}, - expression_attribute_values={":tok": lease_token, ":now": now_unix}, - ), TransactPut( item=FaceTheoryCacheMetadata( pk=pk, @@ -164,8 +185,10 @@ table.transact_write( TransactDelete( pk=pk, sk="LOCK", + condition_expression="#tok = :tok AND #exp > :now", + expression_attribute_names={"#tok": "lease_token", "#exp": "lease_expires_at"}, + expression_attribute_values={":tok": lease_token, ":now": now_unix}, ), ] ) ``` - diff --git a/ts/src/client.ts b/ts/src/client.ts index 32933d9..6150036 100644 --- a/ts/src/client.ts +++ b/ts/src/client.ts @@ -511,6 +511,9 @@ export class TheorydbClient { Delete: { TableName: model.tableName, Key: marshalKey(model, a.key), + ConditionExpression: a.conditionExpression, + ExpressionAttributeNames: a.expressionAttributeNames, + ExpressionAttributeValues: a.expressionAttributeValues, } satisfies Delete, }); break; diff --git a/ts/src/facetheory-isr.ts b/ts/src/facetheory-isr.ts new file mode 100644 index 0000000..1adf5fd --- /dev/null +++ b/ts/src/facetheory-isr.ts @@ -0,0 +1,302 @@ +import type { DynamoDBClient } from '@aws-sdk/client-dynamodb'; + +import { TheorydbClient } from './client.js'; +import { TheorydbError } from './errors.js'; +import { LeaseManager } from './lease.js'; +import { defineModel, type Model } from './model.js'; + +export type FaceTheoryIsrMeta = { + htmlPointer: string; + generatedAtMs: number; + revalidateSeconds: number; + etag?: string; +}; + +export type FaceTheoryIsrLease = { + leaseToken: string; + leaseExpiresAtMs: number; +}; + +export type FaceTheoryIsrMetaStoreGetArgs = { + cacheKey: string; +}; + +export type FaceTheoryIsrMetaStoreTryAcquireLeaseArgs = { + cacheKey: string; + leaseOwner?: string; + nowMs?: number; + leaseDurationMs: number; +}; + +export type FaceTheoryIsrMetaStoreCommitGenerationArgs = { + cacheKey: string; + leaseOwner?: string; + leaseToken: string; + nowMs?: number; + htmlPointer: string; + generatedAtMs: number; + revalidateSeconds: number; + etag?: string; + ttlUnixSeconds?: number; +}; + +export type FaceTheoryIsrMetaStoreReleaseLeaseArgs = { + cacheKey: string; + leaseOwner?: string; + leaseToken: string; +}; + +export type FaceTheoryIsrMetaStoreConfig = { + ddb: DynamoDBClient; + tableName: string; + + client?: TheorydbClient; + + pkFromCacheKey?: (cacheKey: string) => string; + pkPrefix?: string; + + leaseTtlBufferSeconds?: number; + leaseToken?: () => string; + + metaTtlSeconds?: number; +}; + +export function defineFaceTheoryCacheMetadataModel(tableName: string): Model { + return defineModel({ + name: 'FaceTheoryCacheMetadata', + table: { name: tableName }, + keys: { + partition: { attribute: 'pk', type: 'S' }, + sort: { attribute: 'sk', type: 'S' }, + }, + attributes: [ + { attribute: 'pk', type: 'S', roles: ['pk'] }, + { attribute: 'sk', type: 'S', roles: ['sk'] }, + { attribute: 's3_key', type: 'S', required: true }, + { attribute: 'generated_at', type: 'N', required: true }, + { attribute: 'revalidate_seconds', type: 'N', required: true }, + { attribute: 'etag', type: 'S', optional: true }, + { attribute: 'ttl', type: 'N', roles: ['ttl'], optional: true }, + ], + }); +} + +export function defineFaceTheoryCacheLeaseModel(tableName: string): Model { + return defineModel({ + name: 'FaceTheoryCacheLease', + table: { name: tableName }, + keys: { + partition: { attribute: 'pk', type: 'S' }, + sort: { attribute: 'sk', type: 'S' }, + }, + attributes: [ + { attribute: 'pk', type: 'S', roles: ['pk'] }, + { attribute: 'sk', type: 'S', roles: ['sk'] }, + { attribute: 'lease_token', type: 'S', required: true }, + { attribute: 'lease_expires_at', type: 'N', required: true }, + { attribute: 'ttl', type: 'N', roles: ['ttl'], optional: true }, + ], + }); +} + +const META_SK = 'META'; +const LOCK_SK = 'LOCK'; + +export class FaceTheoryIsrMetaStore { + private readonly ddb: DynamoDBClient; + private readonly tableName: string; + private readonly client: TheorydbClient; + private readonly pkFromCacheKey: (cacheKey: string) => string; + private readonly leaseTtlBufferSeconds: number; + private readonly leaseToken: (() => string) | undefined; + private readonly metaTtlSeconds: number | undefined; + + constructor(cfg: FaceTheoryIsrMetaStoreConfig) { + if (!cfg?.ddb) throw new Error('ddb is required'); + if (!cfg?.tableName) throw new Error('tableName is required'); + + this.ddb = cfg.ddb; + this.tableName = cfg.tableName; + this.leaseTtlBufferSeconds = cfg.leaseTtlBufferSeconds ?? 60 * 60; + this.leaseToken = cfg.leaseToken; + this.metaTtlSeconds = cfg.metaTtlSeconds; + + this.pkFromCacheKey = + cfg.pkFromCacheKey ?? + ((cacheKey) => `${cfg.pkPrefix ?? 'CACHE#'}${cacheKey}`); + + this.client = + cfg.client ?? + new TheorydbClient(this.ddb).register( + defineFaceTheoryCacheMetadataModel(this.tableName), + defineFaceTheoryCacheLeaseModel(this.tableName), + ); + + // Ensure models exist when caller provided a pre-configured client. + if (cfg.client) { + this.client.register( + defineFaceTheoryCacheMetadataModel(this.tableName), + defineFaceTheoryCacheLeaseModel(this.tableName), + ); + } + } + + async get( + args: FaceTheoryIsrMetaStoreGetArgs, + ): Promise { + const cacheKey = args.cacheKey; + const pk = this.pkFromCacheKey(cacheKey); + + try { + const item = await this.client.get('FaceTheoryCacheMetadata', { + pk, + sk: META_SK, + }); + return { + htmlPointer: item.s3_key as string, + generatedAtMs: Math.floor((item.generated_at as number) * 1000), + revalidateSeconds: item.revalidate_seconds as number, + ...(typeof item.etag === 'string' ? { etag: item.etag } : {}), + }; + } catch (err) { + if (err instanceof TheorydbError && err.code === 'ErrItemNotFound') { + return null; + } + throw err; + } + } + + async tryAcquireLease( + args: FaceTheoryIsrMetaStoreTryAcquireLeaseArgs, + ): Promise { + const cacheKey = args.cacheKey; + const pk = this.pkFromCacheKey(cacheKey); + + if (!Number.isFinite(args.leaseDurationMs) || args.leaseDurationMs <= 0) { + throw new Error('leaseDurationMs must be > 0'); + } + + const nowUnix = + args.nowMs === undefined ? undefined : Math.floor(args.nowMs / 1000); + + const mgr = new LeaseManager(this.ddb, this.tableName, { + ...(nowUnix === undefined ? {} : { now: () => nowUnix }), + ...(this.leaseToken ? { token: this.leaseToken } : {}), + ttlBufferSeconds: this.leaseTtlBufferSeconds, + }); + + try { + const lease = await mgr.acquire( + { pk, sk: LOCK_SK }, + { + leaseSeconds: Math.ceil(args.leaseDurationMs / 1000), + }, + ); + return { + leaseToken: lease.token, + leaseExpiresAtMs: lease.expiresAt * 1000, + }; + } catch (err) { + if (err instanceof TheorydbError && err.code === 'ErrLeaseHeld') { + return null; + } + throw err; + } + } + + async commitGeneration( + args: FaceTheoryIsrMetaStoreCommitGenerationArgs, + ): Promise { + const cacheKey = args.cacheKey; + const pk = this.pkFromCacheKey(cacheKey); + + if (!args.leaseToken) throw new Error('leaseToken is required'); + if (!args.htmlPointer) throw new Error('htmlPointer is required'); + if (!Number.isFinite(args.generatedAtMs) || args.generatedAtMs <= 0) { + throw new Error('generatedAtMs must be > 0'); + } + if ( + !Number.isFinite(args.revalidateSeconds) || + args.revalidateSeconds <= 0 + ) { + throw new Error('revalidateSeconds must be > 0'); + } + + const nowUnix = + args.nowMs === undefined + ? Math.floor(Date.now() / 1000) + : Math.floor(args.nowMs / 1000); + const generatedAtUnix = Math.floor(args.generatedAtMs / 1000); + + const ttlUnixSeconds = + args.ttlUnixSeconds ?? + (this.metaTtlSeconds === undefined + ? undefined + : generatedAtUnix + this.metaTtlSeconds); + + try { + await this.client.transactWrite([ + { + kind: 'put', + model: 'FaceTheoryCacheMetadata', + item: { + pk, + sk: META_SK, + s3_key: args.htmlPointer, + generated_at: generatedAtUnix, + revalidate_seconds: args.revalidateSeconds, + ...(typeof args.etag === 'string' ? { etag: args.etag } : {}), + ...(ttlUnixSeconds === undefined ? {} : { ttl: ttlUnixSeconds }), + }, + }, + { + kind: 'delete', + model: 'FaceTheoryCacheLease', + key: { pk, sk: LOCK_SK }, + conditionExpression: '#tok = :tok AND #exp > :now', + expressionAttributeNames: { + '#tok': 'lease_token', + '#exp': 'lease_expires_at', + }, + expressionAttributeValues: { + ':tok': { S: args.leaseToken }, + ':now': { N: String(nowUnix) }, + }, + }, + ]); + } catch (err) { + if (err instanceof TheorydbError && err.code === 'ErrConditionFailed') { + throw new TheorydbError('ErrLeaseNotOwned', 'Lease not owned', { + cause: err, + }); + } + throw err; + } + } + + async releaseLease( + args: FaceTheoryIsrMetaStoreReleaseLeaseArgs, + ): Promise { + const cacheKey = args.cacheKey; + const pk = this.pkFromCacheKey(cacheKey); + + if (!args.leaseToken) throw new Error('leaseToken is required'); + + const mgr = new LeaseManager(this.ddb, this.tableName, { + ...(this.leaseToken ? { token: this.leaseToken } : {}), + ttlBufferSeconds: this.leaseTtlBufferSeconds, + }); + + await mgr.release({ + key: { pk, sk: LOCK_SK }, + token: args.leaseToken, + expiresAt: 0, + }); + } +} + +export function createFaceTheoryIsrMetaStore( + cfg: FaceTheoryIsrMetaStoreConfig, +): FaceTheoryIsrMetaStore { + return new FaceTheoryIsrMetaStore(cfg); +} diff --git a/ts/src/index.ts b/ts/src/index.ts index 4ab8d92..6e1d451 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -19,3 +19,4 @@ export * from './send-options.js'; export * from './validation.js'; export * from './protection.js'; export * from './lease.js'; +export * from './facetheory-isr.js'; diff --git a/ts/src/transaction.ts b/ts/src/transaction.ts index f45a013..bd9d1db 100644 --- a/ts/src/transaction.ts +++ b/ts/src/transaction.ts @@ -37,6 +37,9 @@ export type TransactAction = kind: 'delete'; model: string; key: Record; + conditionExpression?: string; + expressionAttributeNames?: Record; + expressionAttributeValues?: Record; } | { kind: 'condition'; diff --git a/ts/test/integration/facetheory-isr.test.ts b/ts/test/integration/facetheory-isr.test.ts new file mode 100644 index 0000000..cea19e6 --- /dev/null +++ b/ts/test/integration/facetheory-isr.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; + +import { + CreateTableCommand, + DescribeTableCommand, + DynamoDBClient, + ResourceInUseException, +} from '@aws-sdk/client-dynamodb'; + +import { TheorydbError } from '../../src/errors.js'; +import { FaceTheoryIsrMetaStore } from '../../src/facetheory-isr.js'; + +const endpoint = process.env.DYNAMODB_ENDPOINT ?? 'http://localhost:8000'; + +const ddb = new DynamoDBClient({ + region: process.env.AWS_REGION ?? 'us-east-1', + endpoint, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? 'dummy', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? 'dummy', + }, +}); + +const tableName = 'facetheory_isr_contract'; + +try { + await ensureIsrTable(ddb, tableName); + + { + const cacheKey = `ts-${Date.now()}`; + + const store1 = new FaceTheoryIsrMetaStore({ + ddb, + tableName, + leaseToken: () => 'tok1', + leaseTtlBufferSeconds: 10, + }); + + const store2 = new FaceTheoryIsrMetaStore({ + ddb, + tableName, + leaseToken: () => 'tok2', + leaseTtlBufferSeconds: 10, + }); + + const lease1 = await store1.tryAcquireLease({ + cacheKey, + nowMs: 1_000_000, + leaseDurationMs: 30_000, + }); + assert.ok(lease1); + assert.equal(lease1.leaseToken, 'tok1'); + + const lease2 = await store2.tryAcquireLease({ + cacheKey, + nowMs: 1_000_000, + leaseDurationMs: 30_000, + }); + assert.equal(lease2, null); + + await store1.commitGeneration({ + cacheKey, + leaseToken: 'tok1', + nowMs: 1_000_000, + htmlPointer: 's3://bucket/key.html', + generatedAtMs: 1_000_000, + revalidateSeconds: 60, + etag: '"abc"', + }); + + const got = await store1.get({ cacheKey }); + assert.deepEqual(got, { + htmlPointer: 's3://bucket/key.html', + generatedAtMs: 1_000_000, + revalidateSeconds: 60, + etag: '"abc"', + }); + + // Commit publishes META and releases the LOCK, so another contender can acquire immediately. + const lease3 = await store2.tryAcquireLease({ + cacheKey, + nowMs: 1_000_000, + leaseDurationMs: 30_000, + }); + assert.ok(lease3); + assert.equal(lease3.leaseToken, 'tok2'); + } + + { + const cacheKey = `ts-stale-${Date.now()}`; + + const store1 = new FaceTheoryIsrMetaStore({ + ddb, + tableName, + leaseToken: () => 'tok1', + leaseTtlBufferSeconds: 10, + }); + + const store2 = new FaceTheoryIsrMetaStore({ + ddb, + tableName, + leaseToken: () => 'tok2', + leaseTtlBufferSeconds: 10, + }); + + const lease1 = await store1.tryAcquireLease({ + cacheKey, + nowMs: 1_000_000, + leaseDurationMs: 30_000, + }); + assert.ok(lease1); + assert.equal(lease1.leaseToken, 'tok1'); + + // After expiry, a new contender can acquire. + const lease2 = await store2.tryAcquireLease({ + cacheKey, + nowMs: 2_000_000, + leaseDurationMs: 30_000, + }); + assert.ok(lease2); + assert.equal(lease2.leaseToken, 'tok2'); + + await assert.rejects( + () => + store1.commitGeneration({ + cacheKey, + leaseToken: 'tok1', + nowMs: 2_000_000, + htmlPointer: 's3://bucket/stale.html', + generatedAtMs: 2_000_000, + revalidateSeconds: 60, + }), + (e) => e instanceof TheorydbError && e.code === 'ErrLeaseNotOwned', + ); + } +} finally { + ddb.destroy(); +} + +async function ensureIsrTable( + client: DynamoDBClient, + name: string, +): Promise { + try { + await client.send(new DescribeTableCommand({ TableName: name })); + return; + } catch { + // continue + } + + try { + await client.send( + new CreateTableCommand({ + TableName: name, + AttributeDefinitions: [ + { AttributeName: 'pk', AttributeType: 'S' }, + { AttributeName: 'sk', AttributeType: 'S' }, + ], + KeySchema: [ + { AttributeName: 'pk', KeyType: 'HASH' }, + { AttributeName: 'sk', KeyType: 'RANGE' }, + ], + ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 }, + }), + ); + } catch (err) { + if (err instanceof ResourceInUseException) return; + if ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name?: unknown }).name === 'ResourceInUseException' + ) { + return; + } + throw err; + } +} diff --git a/ts/test/run-integration.ts b/ts/test/run-integration.ts index daddb54..cb1d88c 100644 --- a/ts/test/run-integration.ts +++ b/ts/test/run-integration.ts @@ -3,3 +3,4 @@ await import('./integration/p0.test.js'); await import('./integration/query.test.js'); await import('./integration/batch-tx.test.js'); await import('./integration/lease.test.js'); +await import('./integration/facetheory-isr.test.js'); diff --git a/ts/test/run-unit.ts b/ts/test/run-unit.ts index fc87cde..20fe3cf 100644 --- a/ts/test/run-unit.ts +++ b/ts/test/run-unit.ts @@ -21,3 +21,4 @@ await import('./unit/multiaccount.test.js'); await import('./unit/validation.test.js'); await import('./unit/protection.test.js'); await import('./unit/lease.test.js'); +await import('./unit/facetheory-isr.test.js'); diff --git a/ts/test/unit/facetheory-isr.test.ts b/ts/test/unit/facetheory-isr.test.ts new file mode 100644 index 0000000..7228123 --- /dev/null +++ b/ts/test/unit/facetheory-isr.test.ts @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; + +import { + ConditionalCheckFailedException, + DeleteItemCommand, + GetItemCommand, + PutItemCommand, + TransactWriteItemsCommand, + TransactionCanceledException, + type DynamoDBClient, +} from '@aws-sdk/client-dynamodb'; + +import { TheorydbError } from '../../src/errors.js'; +import { FaceTheoryIsrMetaStore } from '../../src/facetheory-isr.js'; +import { createMockDynamoDBClient } from '../../src/testkit/index.js'; + +{ + const mock = createMockDynamoDBClient(); + mock.when(GetItemCommand, async () => ({ $metadata: {} })); + + const store = new FaceTheoryIsrMetaStore({ + ddb: mock.client as unknown as DynamoDBClient, + tableName: 'tbl', + }); + + const got = await store.get({ cacheKey: 'a' }); + assert.equal(got, null); +} + +{ + const mock = createMockDynamoDBClient(); + mock.when(GetItemCommand, async () => ({ + $metadata: {}, + Item: { + pk: { S: 'CACHE#a' }, + sk: { S: 'META' }, + s3_key: { S: 's3://bucket/key.html' }, + generated_at: { N: '1000' }, + revalidate_seconds: { N: '60' }, + etag: { S: '"abc"' }, + }, + })); + + const store = new FaceTheoryIsrMetaStore({ + ddb: mock.client as unknown as DynamoDBClient, + tableName: 'tbl', + }); + + const got = await store.get({ cacheKey: 'a' }); + assert.deepEqual(got, { + htmlPointer: 's3://bucket/key.html', + generatedAtMs: 1_000_000, + revalidateSeconds: 60, + etag: '"abc"', + }); + + const cmd = mock.calls[0]; + assert.ok(cmd instanceof GetItemCommand); + assert.equal(cmd.input.TableName, 'tbl'); + assert.equal(cmd.input.ConsistentRead, true); + assert.equal(cmd.input.Key?.pk?.S, 'CACHE#a'); + assert.equal(cmd.input.Key?.sk?.S, 'META'); +} + +{ + const mock = createMockDynamoDBClient(); + mock.when(PutItemCommand, async () => ({ $metadata: {} })); + + const store = new FaceTheoryIsrMetaStore({ + ddb: mock.client as unknown as DynamoDBClient, + tableName: 'tbl', + leaseToken: () => 'tok', + leaseTtlBufferSeconds: 0, + }); + + const lease = await store.tryAcquireLease({ + cacheKey: 'a', + nowMs: 1_000_000, + leaseDurationMs: 30_000, + }); + assert.deepEqual(lease, { leaseToken: 'tok', leaseExpiresAtMs: 1_030_000 }); + + const cmd = mock.calls[0]; + assert.ok(cmd instanceof PutItemCommand); + assert.equal(cmd.input.TableName, 'tbl'); + assert.equal(cmd.input.Item?.pk?.S, 'CACHE#a'); + assert.equal(cmd.input.Item?.sk?.S, 'LOCK'); + assert.equal(cmd.input.Item?.lease_token?.S, 'tok'); + assert.equal(cmd.input.Item?.lease_expires_at?.N, '1030'); + assert.equal(cmd.input.Item?.ttl, undefined); +} + +{ + const mock = createMockDynamoDBClient(); + mock.when(PutItemCommand, async () => { + throw new ConditionalCheckFailedException({ $metadata: {}, message: 'no' }); + }); + + const store = new FaceTheoryIsrMetaStore({ + ddb: mock.client as unknown as DynamoDBClient, + tableName: 'tbl', + leaseToken: () => 'tok', + leaseTtlBufferSeconds: 0, + }); + + const lease = await store.tryAcquireLease({ + cacheKey: 'a', + nowMs: 1_000_000, + leaseDurationMs: 30_000, + }); + assert.equal(lease, null); +} + +{ + const mock = createMockDynamoDBClient(); + mock.when(TransactWriteItemsCommand, async () => ({ $metadata: {} })); + + const store = new FaceTheoryIsrMetaStore({ + ddb: mock.client as unknown as DynamoDBClient, + tableName: 'tbl', + }); + + await store.commitGeneration({ + cacheKey: 'a', + leaseToken: 'tok', + nowMs: 1_000_000, + htmlPointer: 's3://bucket/key.html', + generatedAtMs: 1_000_000, + revalidateSeconds: 60, + etag: '"abc"', + }); + + const cmd = mock.calls[0]; + assert.ok(cmd instanceof TransactWriteItemsCommand); + assert.equal(cmd.input.TransactItems?.length, 2); + + const [put, del] = cmd.input.TransactItems ?? []; + + assert.equal(put?.Put?.TableName, 'tbl'); + assert.equal(put?.Put?.Item?.pk?.S, 'CACHE#a'); + assert.equal(put?.Put?.Item?.sk?.S, 'META'); + assert.equal(put?.Put?.Item?.s3_key?.S, 's3://bucket/key.html'); + assert.equal(put?.Put?.Item?.generated_at?.N, '1000'); + assert.equal(put?.Put?.Item?.revalidate_seconds?.N, '60'); + assert.equal(put?.Put?.Item?.etag?.S, '"abc"'); + assert.equal(put?.Put?.Item?.ttl, undefined); + + assert.equal(del?.Delete?.TableName, 'tbl'); + assert.equal(del?.Delete?.Key?.pk?.S, 'CACHE#a'); + assert.equal(del?.Delete?.Key?.sk?.S, 'LOCK'); + assert.equal(del?.Delete?.ConditionExpression, '#tok = :tok AND #exp > :now'); + assert.equal(del?.Delete?.ExpressionAttributeNames?.['#tok'], 'lease_token'); + assert.equal( + del?.Delete?.ExpressionAttributeNames?.['#exp'], + 'lease_expires_at', + ); + assert.equal(del?.Delete?.ExpressionAttributeValues?.[':tok']?.S, 'tok'); + assert.equal(del?.Delete?.ExpressionAttributeValues?.[':now']?.N, '1000'); +} + +{ + const mock = createMockDynamoDBClient(); + mock.when(TransactWriteItemsCommand, async () => { + throw new TransactionCanceledException({ $metadata: {}, message: 'no' }); + }); + + const store = new FaceTheoryIsrMetaStore({ + ddb: mock.client as unknown as DynamoDBClient, + tableName: 'tbl', + }); + + await assert.rejects( + () => + store.commitGeneration({ + cacheKey: 'a', + leaseToken: 'tok', + nowMs: 1_000_000, + htmlPointer: 's3://bucket/key.html', + generatedAtMs: 1_000_000, + revalidateSeconds: 60, + }), + (e) => e instanceof TheorydbError && e.code === 'ErrLeaseNotOwned', + ); +} + +{ + const mock = createMockDynamoDBClient(); + mock.when(DeleteItemCommand, async () => ({ $metadata: {} })); + + const store = new FaceTheoryIsrMetaStore({ + ddb: mock.client as unknown as DynamoDBClient, + tableName: 'tbl', + leaseTtlBufferSeconds: 0, + }); + + await store.releaseLease({ cacheKey: 'a', leaseToken: 'tok' }); + const cmd = mock.calls[0]; + assert.ok(cmd instanceof DeleteItemCommand); + assert.equal(cmd.input.TableName, 'tbl'); + assert.equal(cmd.input.Key?.pk?.S, 'CACHE#a'); + assert.equal(cmd.input.Key?.sk?.S, 'LOCK'); +}