From 22ffedcfccff7f4732eaad50473a89bcffd7815e Mon Sep 17 00:00:00 2001 From: Aron Price Date: Mon, 23 Mar 2026 14:46:08 -0400 Subject: [PATCH 1/5] feat: add TTL archival lifecycle support --- CHANGELOG.md | 7 + docs/cdk/README.md | 30 +- examples/cdk-multilang/README.md | 13 +- .../cdk-multilang/lambdas/archive/handler.ts | 184 +++++ .../cdk-multilang/lib/multilang-demo-stack.ts | 27 + .../lib/tabletheory-ttl-archive.ts | 112 +++ examples/cdk-multilang/package-lock.json | 717 ++++++++++++++---- examples/cdk-multilang/package.json | 4 +- .../test/tabletheory-ttl-archive.test.ts | 120 +++ pkg/schema/automigrate_unit_test.go | 2 +- .../cov6_manager_error_branches_test.go | 2 +- pkg/schema/manager.go | 29 +- pkg/schema/ttl.go | 56 ++ pkg/schema/ttl_test.go | 102 +++ py/src/theorydb_py/__init__.py | 14 +- py/src/theorydb_py/mocks.py | 3 + py/src/theorydb_py/schema.py | 62 +- py/tests/unit/test_schema.py | 66 ++ ts/src/schema.ts | 53 +- ts/test/unit/schema.test.ts | 63 ++ 20 files changed, 1519 insertions(+), 147 deletions(-) create mode 100644 examples/cdk-multilang/lambdas/archive/handler.ts create mode 100644 examples/cdk-multilang/lib/tabletheory-ttl-archive.ts create mode 100644 examples/cdk-multilang/test/tabletheory-ttl-archive.test.ts create mode 100644 pkg/schema/ttl.go create mode 100644 pkg/schema/ttl_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 411282a..754ff0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Features + +* add TTL-aware schema provisioning across Go, TypeScript, and Python helpers +* add CDK archival construct for DynamoDB TTL expirations to S3 Glacier lifecycle storage + ## [1.4.1](https://github.com/theory-cloud/TableTheory/compare/v1.4.0...v1.4.1) (2026-02-24) diff --git a/docs/cdk/README.md b/docs/cdk/README.md index 13d5d33..e6345d3 100644 --- a/docs/cdk/README.md +++ b/docs/cdk/README.md @@ -209,6 +209,34 @@ idempotencyTable.addGlobalSecondaryIndex({ 4. **Billing Mode**: TableTheory works with both PAY_PER_REQUEST and PROVISIONED 5. **Streams**: Enable if you need change data capture or event processing +### TTL and Archival Lifecycle + +- TableTheory schema helpers now enable DynamoDB TTL automatically when the model declares a `ttl` field or role. +- Keep the same attribute name in your CDK table definition and your model tags so DMS-driven schemas stay aligned. +- For retention workflows that archive TTL deletes, enable `NEW_AND_OLD_IMAGES` streams and wire the archival path to S3. +- The deployable reference lives in `examples/cdk-multilang/lib/tabletheory-ttl-archive.ts` and uses: + - DynamoDB Streams `REMOVE` records created by TTL expiration + - a Lambda archiver at `examples/cdk-multilang/lambdas/archive/handler.ts` + - S3 lifecycle rules for Glacier transition and eventual expiration + +```typescript +const evidenceTable = new dynamodb.Table(this, 'EvidenceArchiveTable', { + partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + timeToLiveAttribute: 'expires_at', +}); + +new TableTheoryTtlArchive(this, 'EvidenceArchive', { + table: evidenceTable, + ttlAttributeName: 'expires_at', + archivePrefix: 'evidence-snapshots', + glacierTransitionAfter: Duration.days(30), + expireAfter: Duration.days(730), +}); +``` + ## 3. Runtime Integration ### Environment Variables @@ -808,4 +836,4 @@ This guide provides the foundation for integrating TableTheory with Lift CDK con 4. **Integration**: Implement Limited library backend using TableTheory 5. **Best Practices**: Follow TableTheory patterns for performance and reliability -For further questions or specific implementation details, refer to the TableTheory examples in `/examples/` directory or the comprehensive test suite in the repository. \ No newline at end of file +For further questions or specific implementation details, refer to the TableTheory examples in `/examples/` directory or the comprehensive test suite in the repository. diff --git a/examples/cdk-multilang/README.md b/examples/cdk-multilang/README.md index e053026..494af18 100644 --- a/examples/cdk-multilang/README.md +++ b/examples/cdk-multilang/README.md @@ -1,11 +1,18 @@ # TableTheory CDK Multi-language Demo -Deploys **one DynamoDB table** and **three Lambdas** (Go, Node.js 24, Python 3.14) that read/write the same item -shape. This is the deployable “proof” that the multi-language TableTheory stack can share a single table without drift. +Deploys **two DynamoDB tables** and **four Lambdas**: + +- one shared application table exercised by Go, Node.js 24, and Python 3.14 +- one TTL-driven evidence table with a DynamoDB Streams to S3 archival Lambda + +This is the deployable “proof” that the multi-language TableTheory stack can share a single table without drift while +also supporting TTL-based retention pipelines. This demo also exercises: + - **Encryption** (KMS envelope, cross-language decrypt) - **Batching** (BatchWrite + BatchGet) +- **TTL + archival** (DynamoDB TTL, Streams, S3 lifecycle to Glacier) - **Transactions** (TransactWrite) ## Commands @@ -14,6 +21,7 @@ From the repo root: - Install deps: `npm --prefix examples/cdk-multilang ci` - Synthesize: `npm --prefix examples/cdk-multilang run synth` +- Test: `npm --prefix examples/cdk-multilang run test` - Deploy (writes `cdk.outputs.json`): `AWS_PROFILE=... npm --prefix examples/cdk-multilang run deploy -- --profile $AWS_PROFILE --outputs-file cdk.outputs.json` After deploy, the stack outputs three Function URLs. Use them to `GET`/`PUT` items: @@ -22,6 +30,7 @@ After deploy, the stack outputs three Function URLs. Use them to `GET`/`PUT` ite - `PUT` with JSON body: `{"pk":"...","sk":"...","value":"...","secret":"..."}` Additional endpoints: + - `PUT /enc` (encryption demo; same payload as `PUT /`) - `POST /batch` (batch write + batch get): `{"pk":"...","skPrefix":"...","count":3,"value":"...","secret":"..."}` - `POST /tx` (transaction write): `{"pk":"...","skPrefix":"...","value":"...","secret":"..."}` diff --git a/examples/cdk-multilang/lambdas/archive/handler.ts b/examples/cdk-multilang/lambdas/archive/handler.ts new file mode 100644 index 0000000..364a040 --- /dev/null +++ b/examples/cdk-multilang/lambdas/archive/handler.ts @@ -0,0 +1,184 @@ +import { + PutObjectCommand, + S3Client, + type PutObjectCommandInput, +} from "@aws-sdk/client-s3"; + +type StreamImage = Record; + +export interface StreamRecord { + eventID?: string; + eventName?: string; + eventSourceARN?: string; + userIdentity?: { + type?: string; + principalId?: string; + }; + dynamodb?: { + ApproximateCreationDateTime?: number; + Keys?: StreamImage; + OldImage?: StreamImage; + }; +} + +export interface ArchiveWriter { + putObject(input: PutObjectCommandInput): Promise; +} + +export interface ArchiveOptions { + archivePrefix?: string; + bucketName: string; + now?: () => Date; + ttlAttributeName: string; + uploadConcurrency?: number; + writer: ArchiveWriter; +} + +export interface ArchiveResult { + archived: number; + batchItemFailures: Array<{ itemIdentifier: string }>; + skipped: number; +} + +export function isTtlExpiredRecord(record: StreamRecord): boolean { + return ( + record.eventName === "REMOVE" && + record.userIdentity?.type === "Service" && + record.userIdentity?.principalId === "dynamodb.amazonaws.com" && + record.dynamodb?.OldImage !== undefined + ); +} + +export async function archiveExpiredRecords( + records: readonly StreamRecord[], + opts: ArchiveOptions, +): Promise { + const ttlRecords = records.filter(isTtlExpiredRecord); + const now = opts.now ?? (() => new Date()); + const prefix = normalizePrefix(opts.archivePrefix ?? "ttl-archive"); + const uploadConcurrency = Math.max( + 1, + Math.floor(opts.uploadConcurrency ?? 25), + ); + const batchItemFailures: Array<{ itemIdentifier: string }> = []; + + await mapWithConcurrency( + ttlRecords, + uploadConcurrency, + async (record, index) => { + try { + const archivedAt = now(); + await opts.writer.putObject({ + Bucket: opts.bucketName, + Key: buildObjectKey(prefix, archivedAt, record, index), + ContentType: "application/json", + Body: JSON.stringify({ + archived_at: archivedAt.toISOString(), + approximate_creation_date_time: + record.dynamodb?.ApproximateCreationDateTime ?? null, + event_id: record.eventID ?? null, + event_source_arn: record.eventSourceARN ?? null, + keys: record.dynamodb?.Keys ?? null, + old_image: record.dynamodb?.OldImage ?? null, + ttl_attribute: opts.ttlAttributeName, + ttl_value: + record.dynamodb?.OldImage?.[opts.ttlAttributeName] ?? null, + }), + }); + } catch (error) { + if (record.eventID) { + batchItemFailures.push({ itemIdentifier: record.eventID }); + return; + } + throw error; + } + }, + ); + + return { + archived: ttlRecords.length - batchItemFailures.length, + batchItemFailures, + skipped: records.length - ttlRecords.length, + }; +} + +export async function handler(event: { + Records?: StreamRecord[]; +}): Promise<{ batchItemFailures: Array<{ itemIdentifier: string }> }> { + const bucketName = requiredEnv("ARCHIVE_BUCKET_NAME"); + const ttlAttributeName = requiredEnv("TTL_ATTRIBUTE_NAME"); + const archivePrefix = process.env.ARCHIVE_PREFIX ?? "ttl-archive"; + const uploadConcurrency = parsePositiveInt( + process.env.ARCHIVE_UPLOAD_CONCURRENCY, + 25, + ); + + const client = new S3Client({}); + const writer: ArchiveWriter = { + putObject: async (input) => { + await client.send(new PutObjectCommand(input)); + }, + }; + + const result = await archiveExpiredRecords(event.Records ?? [], { + archivePrefix, + bucketName, + ttlAttributeName, + uploadConcurrency, + writer, + }); + + return { batchItemFailures: result.batchItemFailures }; +} + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`missing required environment variable ${name}`); + } + return value; +} + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function normalizePrefix(prefix: string): string { + return prefix.replace(/^\/+|\/+$/g, ""); +} + +function buildObjectKey( + prefix: string, + archivedAt: Date, + record: StreamRecord, + index: number, +): string { + const year = archivedAt.getUTCFullYear(); + const month = String(archivedAt.getUTCMonth() + 1).padStart(2, "0"); + const day = String(archivedAt.getUTCDate()).padStart(2, "0"); + const eventID = record.eventID ?? `record-${index}`; + return `${prefix}/${year}/${month}/${day}/${eventID}.json`; +} + +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const workers = Array.from( + { length: Math.min(concurrency, Math.max(1, items.length)) }, + async (_, workerIndex) => { + for ( + let index = workerIndex; + index < items.length; + index += concurrency + ) { + await fn(items[index], index); + } + }, + ); + await Promise.all(workers); +} diff --git a/examples/cdk-multilang/lib/multilang-demo-stack.ts b/examples/cdk-multilang/lib/multilang-demo-stack.ts index 5638088..daffe7f 100644 --- a/examples/cdk-multilang/lib/multilang-demo-stack.ts +++ b/examples/cdk-multilang/lib/multilang-demo-stack.ts @@ -9,6 +9,8 @@ import * as lambda from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { Construct } from 'constructs'; +import { TableTheoryTtlArchive } from './tabletheory-ttl-archive'; + function repoRootFrom(stackFileDir: string): string { return path.resolve(stackFileDir, '../../..'); } @@ -40,6 +42,25 @@ export class MultilangDemoStack extends Stack { removalPolicy: RemovalPolicy.DESTROY, }); + const evidenceTable = new dynamodb.Table(this, 'EvidenceArchiveTable', { + partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY, + stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + timeToLiveAttribute: 'expires_at', + }); + + const evidenceArchive = new TableTheoryTtlArchive(this, 'EvidenceArchive', { + archivePrefix: 'evidence-snapshots', + autoDeleteObjects: true, + expireAfter: Duration.days(730), + glacierTransitionAfter: Duration.days(30), + removalPolicy: RemovalPolicy.DESTROY, + table: evidenceTable, + ttlAttributeName: 'expires_at', + }); + const key = new kms.Key(this, 'DemoKey', { enableKeyRotation: true, removalPolicy: RemovalPolicy.DESTROY, @@ -194,6 +215,12 @@ export class MultilangDemoStack extends Stack { const pyUrl = pyFn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE }); new CfnOutput(this, 'TableName', { value: table.tableName }); + new CfnOutput(this, 'EvidenceArchiveTableName', { + value: evidenceTable.tableName, + }); + new CfnOutput(this, 'EvidenceArchiveBucketName', { + value: evidenceArchive.archiveBucket.bucketName, + }); new CfnOutput(this, 'KmsKeyArn', { value: key.keyArn }); new CfnOutput(this, 'GoFunctionUrl', { value: goUrl.url }); new CfnOutput(this, 'NodeFunctionUrl', { value: nodeUrl.url }); diff --git a/examples/cdk-multilang/lib/tabletheory-ttl-archive.ts b/examples/cdk-multilang/lib/tabletheory-ttl-archive.ts new file mode 100644 index 0000000..6a52434 --- /dev/null +++ b/examples/cdk-multilang/lib/tabletheory-ttl-archive.ts @@ -0,0 +1,112 @@ +import path from "node:path"; + +import { Duration, RemovalPolicy } from "aws-cdk-lib"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import { Construct } from "constructs"; + +export interface TableTheoryTtlArchiveProps { + archiveBucket?: s3.IBucket; + archivePrefix?: string; + archiveStorageClass?: s3.StorageClass; + autoDeleteObjects?: boolean; + batchSize?: number; + bisectBatchOnError?: boolean; + expireAfter?: Duration; + glacierTransitionAfter?: Duration; + lambdaMemorySize?: number; + lambdaTimeout?: Duration; + maxBatchingWindow?: Duration; + parallelizationFactor?: number; + removalPolicy?: RemovalPolicy; + retryAttempts?: number; + table: dynamodb.ITable; + ttlAttributeName: string; + uploadConcurrency?: number; +} + +export class TableTheoryTtlArchive extends Construct { + readonly archiveBucket: s3.IBucket; + readonly archiverFunction: lambda.Function; + + constructor(scope: Construct, id: string, props: TableTheoryTtlArchiveProps) { + super(scope, id); + + if (!props.table.tableStreamArn) { + throw new Error( + "TableTheoryTtlArchive requires a table with DynamoDB Streams enabled", + ); + } + + const archivePrefix = normalizePrefix(props.archivePrefix ?? "ttl-archive"); + const archiveBucket = + props.archiveBucket ?? + new s3.Bucket(this, "ArchiveBucket", { + autoDeleteObjects: props.autoDeleteObjects ?? false, + encryption: s3.BucketEncryption.S3_MANAGED, + lifecycleRules: [ + { + enabled: true, + ...(archivePrefix ? { prefix: `${archivePrefix}/` } : {}), + ...(props.glacierTransitionAfter + ? { + transitions: [ + { + storageClass: + props.archiveStorageClass ?? + s3.StorageClass.DEEP_ARCHIVE, + transitionAfter: props.glacierTransitionAfter, + }, + ], + } + : {}), + ...(props.expireAfter ? { expiration: props.expireAfter } : {}), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN, + }); + + const entry = path.resolve(__dirname, "../lambdas/archive/handler.ts"); + const archiverFunction = new NodejsFunction(this, "ArchiverFunction", { + bundling: { + target: "node24", + }, + entry, + environment: { + ARCHIVE_BUCKET_NAME: archiveBucket.bucketName, + ARCHIVE_PREFIX: archivePrefix, + ARCHIVE_UPLOAD_CONCURRENCY: String(props.uploadConcurrency ?? 25), + TTL_ATTRIBUTE_NAME: props.ttlAttributeName, + }, + handler: "handler", + memorySize: props.lambdaMemorySize ?? 1024, + runtime: lambda.Runtime.NODEJS_24_X, + timeout: props.lambdaTimeout ?? Duration.minutes(5), + }); + + props.table.grantStreamRead(archiverFunction); + archiveBucket.grantPut(archiverFunction); + + archiverFunction.addEventSource( + new lambdaEventSources.DynamoEventSource(props.table, { + batchSize: props.batchSize ?? 1000, + bisectBatchOnError: props.bisectBatchOnError ?? true, + maxBatchingWindow: props.maxBatchingWindow ?? Duration.seconds(5), + parallelizationFactor: props.parallelizationFactor ?? 10, + retryAttempts: props.retryAttempts ?? 3, + reportBatchItemFailures: true, + startingPosition: lambda.StartingPosition.LATEST, + }), + ); + + this.archiveBucket = archiveBucket; + this.archiverFunction = archiverFunction; + } +} + +function normalizePrefix(prefix: string): string { + return prefix.replace(/^\/+|\/+$/g, ""); +} diff --git a/examples/cdk-multilang/package-lock.json b/examples/cdk-multilang/package-lock.json index 770f2dc..f45a786 100644 --- a/examples/cdk-multilang/package-lock.json +++ b/examples/cdk-multilang/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "3.1002.0", "@aws-sdk/client-kms": "3.1002.0", + "@aws-sdk/client-s3": "3.1002.0", "aws-cdk-lib": "2.241.0", "constructs": "10.5.1" }, @@ -71,6 +72,83 @@ "node": ">=10" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -299,24 +377,103 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core": { - "version": "3.973.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.17.tgz", - "integrity": "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w==", + "node_modules/@aws-sdk/client-s3": { + "version": "3.1002.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1002.0.tgz", + "integrity": "sha512-tc+vZgvjcm+1Ot+YhQjXZxVELKGGGO3D5cuR4p5xaeitXYX2+RRiz4/WdSak9slumIClnlXsdqhJ0OHognUT+w==", "license": "Apache-2.0", "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/credential-provider-node": "^3.972.16", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.6", + "@aws-sdk/middleware-expect-continue": "^3.972.6", + "@aws-sdk/middleware-flexible-checksums": "^3.973.3", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-location-constraint": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-sdk-s3": "^3.972.17", + "@aws-sdk/middleware-ssec": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.5", "@aws-sdk/types": "^3.973.4", - "@aws-sdk/xml-builder": "^3.972.9", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.2", + "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", + "@smithy/eventstream-serde-browser": "^4.2.10", + "@smithy/eventstream-serde-config-resolver": "^4.3.10", + "@smithy/eventstream-serde-node": "^4.2.10", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-blob-browser": "^4.2.11", + "@smithy/hash-node": "^4.2.10", + "@smithy/hash-stream-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/md5-js": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", + "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", - "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", + "@smithy/util-body-length-browser": "^4.2.1", + "@smithy/util-body-length-node": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", + "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", + "@smithy/util-waiter": "^4.2.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.23.tgz", + "integrity": "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.15", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -511,6 +668,24 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-endpoint-discovery": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.6.tgz", @@ -528,6 +703,46 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.3.tgz", + "integrity": "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", @@ -543,6 +758,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", @@ -573,6 +802,45 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.23.tgz", + "integrity": "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-user-agent": { "version": "3.972.17", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.17.tgz", @@ -656,6 +924,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.11.tgz", + "integrity": "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/token-providers": { "version": "3.1002.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1002.0.tgz", @@ -675,12 +960,24 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", - "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -752,13 +1049,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", - "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", + "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { @@ -1217,12 +1514,37 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", - "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -1247,18 +1569,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.8.tgz", - "integrity": "sha512-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA==", + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.12", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -1283,15 +1605,85 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", - "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -1299,6 +1691,21 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/hash-node": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", @@ -1314,6 +1721,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/invalid-dependency": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", @@ -1339,6 +1760,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/middleware-content-length": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", @@ -1354,18 +1789,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.22", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.22.tgz", - "integrity": "sha512-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ==", + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", + "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.8", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-middleware": "^4.2.11", + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -1393,13 +1828,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", - "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1407,12 +1843,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", - "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1420,14 +1856,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", - "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1435,15 +1871,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", - "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1451,12 +1887,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", - "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1464,12 +1900,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", - "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1477,12 +1913,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", - "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -1491,12 +1927,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", - "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1516,12 +1952,12 @@ } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", - "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1529,16 +1965,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", - "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", + "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1548,17 +1984,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.2.tgz", - "integrity": "sha512-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA==", + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", + "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.8", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" }, "engines": { @@ -1566,9 +2002,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1578,13 +2014,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", - "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1714,12 +2150,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", - "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1741,14 +2177,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.17", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", - "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/types": "^4.13.0", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -2258,21 +2694,24 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", "funding": [ { "type": "github", @@ -2281,8 +2720,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -2316,6 +2756,21 @@ "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "license": "MIT" }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2327,9 +2782,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", diff --git a/examples/cdk-multilang/package.json b/examples/cdk-multilang/package.json index 6f1bfb8..1e9833c 100644 --- a/examples/cdk-multilang/package.json +++ b/examples/cdk-multilang/package.json @@ -10,11 +10,13 @@ "scripts": { "prep": "npm --prefix ../../ts ci && npm --prefix ../../ts run build", "synth": "npm run prep && npx -y aws-cdk@2.1109.0 synth", - "deploy": "npm run prep && npx -y aws-cdk@2.1109.0 deploy --require-approval never" + "deploy": "npm run prep && npx -y aws-cdk@2.1109.0 deploy --require-approval never", + "test": "tsx --test test/**/*.test.ts" }, "dependencies": { "@aws-sdk/client-dynamodb": "3.1002.0", "@aws-sdk/client-kms": "3.1002.0", + "@aws-sdk/client-s3": "3.1002.0", "aws-cdk-lib": "2.241.0", "constructs": "10.5.1" }, diff --git a/examples/cdk-multilang/test/tabletheory-ttl-archive.test.ts b/examples/cdk-multilang/test/tabletheory-ttl-archive.test.ts new file mode 100644 index 0000000..2dafbb0 --- /dev/null +++ b/examples/cdk-multilang/test/tabletheory-ttl-archive.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { App, Duration, Stack } from "aws-cdk-lib"; +import { Template } from "aws-cdk-lib/assertions"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; + +import { + archiveExpiredRecords, + type ArchiveWriter, +} from "../lambdas/archive/handler"; +import { TableTheoryTtlArchive } from "../lib/tabletheory-ttl-archive"; + +test("TableTheoryTtlArchive synthesizes lifecycle, lambda, and stream mapping", () => { + const app = new App(); + const stack = new Stack(app, "ArchiveStack"); + const table = new dynamodb.Table(stack, "EvidenceTable", { + partitionKey: { name: "PK", type: dynamodb.AttributeType.STRING }, + sortKey: { name: "SK", type: dynamodb.AttributeType.STRING }, + stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + timeToLiveAttribute: "expires_at", + }); + + new TableTheoryTtlArchive(stack, "Archive", { + archivePrefix: "evidence", + batchSize: 500, + expireAfter: Duration.days(730), + glacierTransitionAfter: Duration.days(30), + parallelizationFactor: 4, + table, + ttlAttributeName: "expires_at", + uploadConcurrency: 40, + }); + + const template = Template.fromStack(stack); + template.hasResourceProperties("AWS::S3::Bucket", { + LifecycleConfiguration: { + Rules: [ + { + ExpirationInDays: 730, + Prefix: "evidence/", + Status: "Enabled", + Transitions: [ + { + StorageClass: "DEEP_ARCHIVE", + TransitionInDays: 30, + }, + ], + }, + ], + }, + }); + template.hasResourceProperties("AWS::Lambda::Function", { + Environment: { + Variables: { + ARCHIVE_PREFIX: "evidence", + ARCHIVE_UPLOAD_CONCURRENCY: "40", + TTL_ATTRIBUTE_NAME: "expires_at", + }, + }, + MemorySize: 1024, + Timeout: 300, + }); + template.hasResourceProperties("AWS::Lambda::EventSourceMapping", { + BatchSize: 500, + BisectBatchOnFunctionError: true, + FunctionResponseTypes: ["ReportBatchItemFailures"], + MaximumBatchingWindowInSeconds: 5, + ParallelizationFactor: 4, + StartingPosition: "LATEST", + }); +}); + +test("archiveExpiredRecords handles evidence-scale ttl batches with bounded concurrency", async () => { + const records = Array.from({ length: 1000 }, (_, index) => ({ + eventID: `evt-${index}`, + eventName: "REMOVE", + userIdentity: { + type: "Service", + principalId: "dynamodb.amazonaws.com", + }, + dynamodb: { + ApproximateCreationDateTime: 1_742_688_000, + Keys: { PK: { S: `merchant#${index}` } }, + OldImage: { + expires_at: { N: "1742688000" }, + payload: { S: `snapshot-${index}` }, + }, + }, + })); + + let activeUploads = 0; + let maxConcurrentUploads = 0; + let uploadCount = 0; + + const writer: ArchiveWriter = { + putObject: async () => { + activeUploads += 1; + maxConcurrentUploads = Math.max(maxConcurrentUploads, activeUploads); + uploadCount += 1; + await new Promise((resolve) => setImmediate(resolve)); + activeUploads -= 1; + }, + }; + + const result = await archiveExpiredRecords(records, { + archivePrefix: "evidence", + bucketName: "archive-bucket", + now: () => new Date("2026-03-23T00:00:00Z"), + ttlAttributeName: "expires_at", + uploadConcurrency: 32, + writer, + }); + + assert.equal(result.archived, 1000); + assert.equal(result.skipped, 0); + assert.deepEqual(result.batchItemFailures, []); + assert.equal(uploadCount, 1000); + assert.ok(maxConcurrentUploads <= 32); +}); diff --git a/pkg/schema/automigrate_unit_test.go b/pkg/schema/automigrate_unit_test.go index 80e5441..0ee1743 100644 --- a/pkg/schema/automigrate_unit_test.go +++ b/pkg/schema/automigrate_unit_test.go @@ -241,5 +241,5 @@ func TestManager_UpdateTable_And_BatchUpdateTable(t *testing.T) { reqs := httpClient.Requests() require.GreaterOrEqual(t, countRequestsByTarget(reqs, "DynamoDB_20120810.DescribeTable"), 2) - require.Equal(t, 2, countRequestsByTarget(reqs, "DynamoDB_20120810.UpdateTable")) + require.Equal(t, 1, countRequestsByTarget(reqs, "DynamoDB_20120810.UpdateTable")) } diff --git a/pkg/schema/cov6_manager_error_branches_test.go b/pkg/schema/cov6_manager_error_branches_test.go index 40eea75..9f7ced5 100644 --- a/pkg/schema/cov6_manager_error_branches_test.go +++ b/pkg/schema/cov6_manager_error_branches_test.go @@ -118,7 +118,7 @@ func TestManager_UpdateTable_WrapsUpdateErrors_COV6(t *testing.T) { mgr := newTestManager(t, httpClient) require.NoError(t, mgr.registry.Register(&cov6ManagerModel{})) - err := mgr.UpdateTable(&cov6ManagerModel{}, WithBillingMode(types.BillingModePayPerRequest)) + err := mgr.UpdateTable(&cov6ManagerModel{}, WithThroughput(5, 5)) require.ErrorContains(t, err, "failed to update table") } diff --git a/pkg/schema/manager.go b/pkg/schema/manager.go index 4a16425..7ea7501 100644 --- a/pkg/schema/manager.go +++ b/pkg/schema/manager.go @@ -112,17 +112,20 @@ func (m *Manager) CreateTable(model any, opts ...TableOption) error { // Check if table already exists var existsErr *types.ResourceInUseException if errors.As(err, &existsErr) { - // Table already exists, which is fine - return nil + return m.syncModelTTL(ctx, client, metadata) } return fmt.Errorf("failed to create table %s: %w", metadata.TableName, err) } // Wait for table to be active waiter := dynamodb.NewTableExistsWaiter(client) - return waiter.Wait(ctx, &dynamodb.DescribeTableInput{ + if err := waiter.Wait(ctx, &dynamodb.DescribeTableInput{ TableName: aws.String(metadata.TableName), - }, 5*time.Minute) + }, 5*time.Minute); err != nil { + return err + } + + return m.syncModelTTL(ctx, client, metadata) } // buildKeySchema builds the primary key schema @@ -385,13 +388,21 @@ func (m *Manager) UpdateTable(model any, opts ...TableOption) error { return fmt.Errorf("failed to get client for table update: %w", err) } + if !hasUpdateTableChanges(input) { + return m.syncModelTTL(ctx, client, metadata) + } + _, err = client.UpdateTable(ctx, input) if err != nil { return fmt.Errorf("failed to update table %s: %w", metadata.TableName, err) } // Wait for update to complete - return m.waitForTableActive(metadata.TableName) + if err := m.waitForTableActive(metadata.TableName); err != nil { + return err + } + + return m.syncModelTTL(ctx, client, metadata) } func buildCreateTableInput(opts []TableOption) *dynamodb.CreateTableInput { @@ -402,6 +413,14 @@ func buildCreateTableInput(opts []TableOption) *dynamodb.CreateTableInput { return createInput } +func hasUpdateTableChanges(input *dynamodb.UpdateTableInput) bool { + return input.BillingMode != "" || + input.ProvisionedThroughput != nil || + input.StreamSpecification != nil || + input.SSESpecification != nil || + len(input.GlobalSecondaryIndexUpdates) > 0 +} + func applyBillingModeUpdate(input *dynamodb.UpdateTableInput, createInput *dynamodb.CreateTableInput, current *types.TableDescription) { if createInput.BillingMode == "" || current.BillingModeSummary == nil { return diff --git a/pkg/schema/ttl.go b/pkg/schema/ttl.go new file mode 100644 index 0000000..a07ba2b --- /dev/null +++ b/pkg/schema/ttl.go @@ -0,0 +1,56 @@ +package schema + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + + "github.com/theory-cloud/tabletheory/pkg/model" +) + +// EnableTTL enables DynamoDB TTL for the ttl-tagged field on an existing model table. +func (m *Manager) EnableTTL(modelValue any) error { + metadata, err := m.registry.GetMetadata(modelValue) + if err != nil { + return fmt.Errorf("failed to get model metadata: %w", err) + } + + if metadata.TTLField == nil { + return fmt.Errorf("model %T does not define a ttl field", modelValue) + } + + ctx := context.Background() + client, err := m.session.Client() + if err != nil { + return fmt.Errorf("failed to get client for ttl update: %w", err) + } + + return m.syncModelTTL(ctx, client, metadata) +} + +func (m *Manager) syncModelTTL(ctx context.Context, client *dynamodb.Client, metadata *model.Metadata) error { + if metadata == nil || metadata.TTLField == nil { + return nil + } + + _, err := client.UpdateTimeToLive(ctx, &dynamodb.UpdateTimeToLiveInput{ + TableName: aws.String(metadata.TableName), + TimeToLiveSpecification: &types.TimeToLiveSpecification{ + AttributeName: aws.String(metadata.TTLField.DBName), + Enabled: aws.Bool(true), + }, + }) + if err != nil { + return fmt.Errorf( + "failed to enable ttl on table %s using attribute %s: %w", + metadata.TableName, + metadata.TTLField.DBName, + err, + ) + } + + return nil +} diff --git a/pkg/schema/ttl_test.go b/pkg/schema/ttl_test.go new file mode 100644 index 0000000..ea5945c --- /dev/null +++ b/pkg/schema/ttl_test.go @@ -0,0 +1,102 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/theory-cloud/tabletheory/pkg/model" +) + +type ttlSchemaModel struct { + ID string `theorydb:"pk"` + ExpiresAt int64 `theorydb:"ttl"` +} + +func (ttlSchemaModel) TableName() string { return "ttl_records" } + +type noTTLModel struct { + ID string `theorydb:"pk"` +} + +func (noTTLModel) TableName() string { return "no_ttl_records" } + +func TestManager_CreateTable_EnablesTTLFromModel_COV6(t *testing.T) { + httpClient := newCapturingHTTPClient(nil) + httpClient.SetResponseSequence("DynamoDB_20120810.CreateTable", []stubbedResponse{ + {body: `{}`}, + }) + httpClient.SetResponseSequence("DynamoDB_20120810.DescribeTable", []stubbedResponse{ + {body: `{"Table":{"TableName":"ttl_records","TableStatus":"ACTIVE","BillingModeSummary":{"BillingMode":"PAY_PER_REQUEST"}}}`}, + }) + httpClient.SetResponseSequence("DynamoDB_20120810.UpdateTimeToLive", []stubbedResponse{ + {body: `{"TimeToLiveSpecification":{"AttributeName":"expiresAt","Enabled":true}}`}, + }) + + mgr := newTestManager(t, httpClient) + require.NoError(t, mgr.registry.Register(&ttlSchemaModel{})) + + require.NoError(t, mgr.CreateTable(&ttlSchemaModel{})) + + reqs := httpClient.Requests() + require.Equal(t, 1, countRequestsByTarget(reqs, "DynamoDB_20120810.CreateTable")) + require.Equal(t, 1, countRequestsByTarget(reqs, "DynamoDB_20120810.UpdateTimeToLive")) + require.Equal(t, map[string]any{ + "AttributeName": "expiresAt", + "Enabled": true, + }, reqs[len(reqs)-1].Payload["TimeToLiveSpecification"]) +} + +func TestManager_CreateTable_ExistingTableStillSyncsTTL_COV6(t *testing.T) { + httpClient := newCapturingHTTPClient(nil) + httpClient.SetResponseSequence("DynamoDB_20120810.CreateTable", []stubbedResponse{ + stubbedAWSError("ResourceInUseException", "exists"), + }) + httpClient.SetResponseSequence("DynamoDB_20120810.UpdateTimeToLive", []stubbedResponse{ + {body: `{"TimeToLiveSpecification":{"AttributeName":"expiresAt","Enabled":true}}`}, + }) + + mgr := newTestManager(t, httpClient) + require.NoError(t, mgr.registry.Register(&ttlSchemaModel{})) + + require.NoError(t, mgr.CreateTable(&ttlSchemaModel{})) + + reqs := httpClient.Requests() + require.Equal(t, 1, countRequestsByTarget(reqs, "DynamoDB_20120810.CreateTable")) + require.Equal(t, 1, countRequestsByTarget(reqs, "DynamoDB_20120810.UpdateTimeToLive")) +} + +func TestManager_UpdateTable_SyncsTTLWithoutOtherTableChanges_COV6(t *testing.T) { + httpClient := newCapturingHTTPClient(map[string]string{ + "DynamoDB_20120810.DescribeTable": `{"Table":{"TableName":"ttl_records","TableStatus":"ACTIVE","BillingModeSummary":{"BillingMode":"PAY_PER_REQUEST"}}}`, + }) + httpClient.SetResponseSequence("DynamoDB_20120810.UpdateTimeToLive", []stubbedResponse{ + {body: `{"TimeToLiveSpecification":{"AttributeName":"expiresAt","Enabled":true}}`}, + }) + + mgr := newTestManager(t, httpClient) + require.NoError(t, mgr.registry.Register(&ttlSchemaModel{})) + + require.NoError(t, mgr.UpdateTable(&ttlSchemaModel{})) + + reqs := httpClient.Requests() + require.Equal(t, 0, countRequestsByTarget(reqs, "DynamoDB_20120810.UpdateTable")) + require.Equal(t, 1, countRequestsByTarget(reqs, "DynamoDB_20120810.UpdateTimeToLive")) +} + +func TestManager_EnableTTL_ValidatesTTLField_COV6(t *testing.T) { + mgr := &Manager{registry: modelRegistryWith(t, &noTTLModel{})} + + err := mgr.EnableTTL(&noTTLModel{}) + require.ErrorContains(t, err, "does not define a ttl field") +} + +func modelRegistryWith(t *testing.T, values ...any) *model.Registry { + t.Helper() + + registry := model.NewRegistry() + for _, value := range values { + require.NoError(t, registry.Register(value)) + } + return registry +} diff --git a/py/src/theorydb_py/__init__.py b/py/src/theorydb_py/__init__.py index f1c84d8..ea5c076 100644 --- a/py/src/theorydb_py/__init__.py +++ b/py/src/theorydb_py/__init__.py @@ -66,7 +66,15 @@ instrument_boto3_client, is_lambda_environment, ) - from .schema import build_create_table_request, create_table, delete_table, describe_table, ensure_table + from .schema import ( + build_create_table_request, + create_table, + delete_table, + describe_table, + ensure_table, + resolve_ttl_attribute, + update_time_to_live, + ) from .streams import unmarshal_stream_image, unmarshal_stream_record from .table import Table from .validation import ( @@ -117,6 +125,8 @@ def __getattr__(name: str) -> Any: "delete_table", "describe_table", "ensure_table", + "resolve_ttl_attribute", + "update_time_to_live", }: from . import schema @@ -223,6 +233,7 @@ def __getattr__(name: str) -> Any: "GroupedResult", "EncryptionNotConfiguredError", "ensure_table", + "resolve_ttl_attribute", "get_dms_model", "get_lambda_boto3_client", "get_lambda_dynamodb_client", @@ -277,6 +288,7 @@ def __getattr__(name: str) -> Any: "parse_dms_document", "unmarshal_stream_image", "unmarshal_stream_record", + "update_time_to_live", "validate_expression", "validate_field_name", "validate_index_name", diff --git a/py/src/theorydb_py/mocks.py b/py/src/theorydb_py/mocks.py index a4ef29e..b86240d 100644 --- a/py/src/theorydb_py/mocks.py +++ b/py/src/theorydb_py/mocks.py @@ -121,6 +121,9 @@ def delete_table(self, **kwargs: Any) -> Mapping[str, Any]: def describe_table(self, **kwargs: Any) -> Mapping[str, Any]: return self._handle("describe_table", kwargs) + def update_time_to_live(self, **kwargs: Any) -> Mapping[str, Any]: + return self._handle("update_time_to_live", kwargs) + class FakeKmsClient: def __init__(self, *, plaintext_key: bytes, ciphertext_blob: bytes) -> None: diff --git a/py/src/theorydb_py/schema.py b/py/src/theorydb_py/schema.py index 80737fe..20bcfaa 100644 --- a/py/src/theorydb_py/schema.py +++ b/py/src/theorydb_py/schema.py @@ -30,6 +30,7 @@ def create_table( if client is None: client = boto3.client("dynamodb") + ttl_attribute = resolve_ttl_attribute(model) req = build_create_table_request( model, table_name=table_name, @@ -44,7 +45,7 @@ def create_table( if code != "ResourceInUseException": raise _map_schema_error(err) from err - if wait_for_active: + if wait_for_active or ttl_attribute is not None: _wait_for_table_active( client, req["TableName"], @@ -53,6 +54,15 @@ def create_table( sleep=sleep, ) + if ttl_attribute is not None: + update_time_to_live( + model, + client=client, + table_name=req["TableName"], + attribute_name=ttl_attribute, + enabled=True, + ) + def ensure_table( model: ModelDefinition[Any], @@ -73,6 +83,7 @@ def ensure_table( if not resolved_table: raise ValueError("table_name is required (or set ModelDefinition.table_name)") + ttl_attribute = resolve_ttl_attribute(model) try: client.describe_table(TableName=resolved_table) except ClientError as err: @@ -92,7 +103,7 @@ def ensure_table( ) return - if wait_for_active: + if wait_for_active or ttl_attribute is not None: _wait_for_table_active( client, resolved_table, @@ -101,6 +112,15 @@ def ensure_table( sleep=sleep, ) + if ttl_attribute is not None: + update_time_to_live( + model, + client=client, + table_name=resolved_table, + attribute_name=ttl_attribute, + enabled=True, + ) + def delete_table( model: ModelDefinition[Any], @@ -157,6 +177,44 @@ def describe_table( raise _map_schema_error(err) from err +def resolve_ttl_attribute(model: ModelDefinition[Any]) -> str | None: + for attr in model.attributes.values(): + if "ttl" in attr.roles: + return attr.attribute_name + return None + + +def update_time_to_live( + model: ModelDefinition[Any], + *, + client: Any | None = None, + table_name: str | None = None, + attribute_name: str | None = None, + enabled: bool = True, +) -> None: + if client is None: + client = boto3.client("dynamodb") + + resolved_table = table_name or model.table_name + if not resolved_table: + raise ValueError("table_name is required (or set ModelDefinition.table_name)") + + resolved_attribute = attribute_name or resolve_ttl_attribute(model) + if resolved_attribute is None: + return + + try: + client.update_time_to_live( + TableName=resolved_table, + TimeToLiveSpecification={ + "AttributeName": resolved_attribute, + "Enabled": enabled, + }, + ) + except ClientError as err: + raise _map_schema_error(err) from err + + def build_create_table_request( model: ModelDefinition[Any], *, diff --git a/py/tests/unit/test_schema.py b/py/tests/unit/test_schema.py index 65cccb2..f63a943 100644 --- a/py/tests/unit/test_schema.py +++ b/py/tests/unit/test_schema.py @@ -14,6 +14,8 @@ delete_table, describe_table, ensure_table, + resolve_ttl_attribute, + update_time_to_live, ) @@ -38,6 +40,19 @@ class Record: ) +@pytest.fixture() +def ttl_model() -> ModelDefinition[object]: + from dataclasses import dataclass + + @dataclass(frozen=True) + class Record: + pk: str = theorydb_field(name="PK", roles=["pk"]) + sk: str = theorydb_field(name="SK", roles=["sk"]) + expires_at: int = theorydb_field(name="expires_at", roles=["ttl"]) + + return ModelDefinition.from_dataclass(Record, table_name="ttl_tbl") + + def test_build_create_table_request_includes_indexes_and_sorted_attributes( model: ModelDefinition[object], ) -> None: @@ -231,6 +246,57 @@ def test_build_create_table_request_requires_dataclass_model_type(model: ModelDe build_create_table_request(bad) +def test_create_table_syncs_ttl_for_models_with_ttl_role(ttl_model: ModelDefinition[object]) -> None: + client = FakeDynamoDBClient() + client.expect("create_table", {"TableName": "ttl_tbl", "BillingMode": "PAY_PER_REQUEST"}, response={}) + client.expect("describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + client.expect( + "update_time_to_live", + { + "TableName": "ttl_tbl", + "TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}, + }, + response={"TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}}, + ) + + create_table(ttl_model, client=client, wait_for_active=False, sleep=lambda _: None) + client.assert_no_pending() + + +def test_ensure_table_syncs_ttl_for_existing_tables(ttl_model: ModelDefinition[object]) -> None: + client = FakeDynamoDBClient() + client.expect("describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + client.expect("describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + client.expect( + "update_time_to_live", + { + "TableName": "ttl_tbl", + "TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}, + }, + response={"TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}}, + ) + + ensure_table(ttl_model, client=client, wait_for_active=False, sleep=lambda _: None) + client.assert_no_pending() + + +def test_resolve_and_update_time_to_live_helpers(ttl_model: ModelDefinition[object]) -> None: + assert resolve_ttl_attribute(ttl_model) == "expires_at" + + client = FakeDynamoDBClient() + client.expect( + "update_time_to_live", + { + "TableName": "ttl_tbl", + "TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}, + }, + response={"TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}}, + ) + + update_time_to_live(ttl_model, client=client) + client.assert_no_pending() + + def test_build_create_table_request_key_type_inference_variants() -> None: from dataclasses import dataclass diff --git a/ts/src/schema.ts b/ts/src/schema.ts index 475e9d5..ffe569a 100644 --- a/ts/src/schema.ts +++ b/ts/src/schema.ts @@ -2,6 +2,7 @@ import { CreateTableCommand, DeleteTableCommand, DescribeTableCommand, + UpdateTimeToLiveCommand, type AttributeDefinition, type CreateTableCommandInput, type DynamoDBClient, @@ -45,12 +46,19 @@ export interface DescribeTableOptions { tableName?: string; } +export interface UpdateTimeToLiveOptions { + tableName?: string; + attributeName?: string; + enabled?: boolean; +} + export async function createTable( ddb: DynamoDBClient, model: Model, opts: CreateTableOptions = {}, ): Promise { const tableName = opts.tableName ?? model.tableName; + const ttlAttribute = resolveTimeToLiveAttribute(model); const input = buildCreateTableInput(model, { tableName, ...(opts.billingMode ? { billingMode: opts.billingMode } : {}), @@ -65,7 +73,7 @@ export async function createTable( if (!isResourceInUse(err)) throw err; } - if (opts.waitForActive ?? true) { + if ((opts.waitForActive ?? true) || ttlAttribute) { const waitOpts: { timeoutSeconds?: number; pollIntervalMs?: number } = {}; if (opts.waitTimeoutSeconds !== undefined) waitOpts.timeoutSeconds = opts.waitTimeoutSeconds; @@ -73,6 +81,14 @@ export async function createTable( waitOpts.pollIntervalMs = opts.pollIntervalMs; await waitForTableActive(ddb, tableName, waitOpts); } + + if (ttlAttribute) { + await updateTimeToLive(ddb, model, { + tableName, + attributeName: ttlAttribute, + enabled: true, + }); + } } export async function ensureTable( @@ -81,6 +97,7 @@ export async function ensureTable( opts: CreateTableOptions = {}, ): Promise { const tableName = opts.tableName ?? model.tableName; + const ttlAttribute = resolveTimeToLiveAttribute(model); try { await describeTable(ddb, model, { tableName }); } catch (err) { @@ -89,7 +106,7 @@ export async function ensureTable( return; } - if (opts.waitForActive ?? true) { + if ((opts.waitForActive ?? true) || ttlAttribute) { const waitOpts: { timeoutSeconds?: number; pollIntervalMs?: number } = {}; if (opts.waitTimeoutSeconds !== undefined) waitOpts.timeoutSeconds = opts.waitTimeoutSeconds; @@ -97,6 +114,14 @@ export async function ensureTable( waitOpts.pollIntervalMs = opts.pollIntervalMs; await waitForTableActive(ddb, tableName, waitOpts); } + + if (ttlAttribute) { + await updateTimeToLive(ddb, model, { + tableName, + attributeName: ttlAttribute, + enabled: true, + }); + } } export async function deleteTable( @@ -153,6 +178,30 @@ export async function describeTable( } } +export function resolveTimeToLiveAttribute(model: Model): string | undefined { + return model.roles.ttl; +} + +export async function updateTimeToLive( + ddb: DynamoDBClient, + model: Model, + opts: UpdateTimeToLiveOptions = {}, +): Promise { + const tableName = opts.tableName ?? model.tableName; + const attributeName = opts.attributeName ?? resolveTimeToLiveAttribute(model); + if (!attributeName) return; + + await ddb.send( + new UpdateTimeToLiveCommand({ + TableName: tableName, + TimeToLiveSpecification: { + AttributeName: attributeName, + Enabled: opts.enabled ?? true, + }, + }), + ); +} + function buildCreateTableInput( model: Model, opts: { diff --git a/ts/test/unit/schema.test.ts b/ts/test/unit/schema.test.ts index 930cdd9..27a5b33 100644 --- a/ts/test/unit/schema.test.ts +++ b/ts/test/unit/schema.test.ts @@ -6,6 +6,7 @@ import { DeleteTableCommand, DescribeTableCommand, type DynamoDBClient, + UpdateTimeToLiveCommand, } from '@aws-sdk/client-dynamodb'; import { TheorydbError } from '../../src/errors.js'; @@ -40,6 +41,19 @@ const baseSchema: ModelSchema = { ], }; +const ttlSchema: ModelSchema = { + ...baseSchema, + attributes: [ + ...baseSchema.attributes, + { + attribute: 'expiresAt', + type: 'N', + optional: true, + roles: ['ttl'], + }, + ], +}; + test('createTable sends CreateTableCommand with model schema', async () => { const model = defineModel(baseSchema); const calls: unknown[] = []; @@ -176,3 +190,52 @@ test('createTable requires throughput when billingMode=PROVISIONED', async () => }, ); }); + +test('createTable syncs TTL when the model declares a ttl role', async () => { + const model = defineModel(ttlSchema); + const calls: unknown[] = []; + const ddb = { + send: async (cmd: unknown) => { + calls.push(cmd); + if (cmd instanceof DescribeTableCommand) { + return { Table: { TableStatus: 'ACTIVE' } }; + } + return {}; + }, + } as unknown as DynamoDBClient; + + await createTable(ddb, model, { waitForActive: false }); + + assert.equal(calls.length, 3); + assert.ok(calls[0] instanceof CreateTableCommand); + assert.ok(calls[1] instanceof DescribeTableCommand); + assert.ok(calls[2] instanceof UpdateTimeToLiveCommand); + assert.deepEqual((calls[2] as UpdateTimeToLiveCommand).input, { + TableName: 'users_contract', + TimeToLiveSpecification: { + AttributeName: 'expiresAt', + Enabled: true, + }, + }); +}); + +test('ensureTable syncs TTL for an existing table', async () => { + const model = defineModel(ttlSchema); + const calls: unknown[] = []; + const ddb = { + send: async (cmd: unknown) => { + calls.push(cmd); + if (cmd instanceof DescribeTableCommand) { + return { Table: { TableStatus: 'ACTIVE' } }; + } + return {}; + }, + } as unknown as DynamoDBClient; + + await ensureTable(ddb, model, { waitForActive: false }); + + assert.equal(calls.length, 3); + assert.ok(calls[0] instanceof DescribeTableCommand); + assert.ok(calls[1] instanceof DescribeTableCommand); + assert.ok(calls[2] instanceof UpdateTimeToLiveCommand); +}); From 0c6b9eec7d38719af075bb09a7908d0a97556ee8 Mon Sep 17 00:00:00 2001 From: Aron Price Date: Mon, 23 Mar 2026 15:09:03 -0400 Subject: [PATCH 2/5] fix: remediate rubric dependency scan failures --- contract-tests/runners/go/go.mod | 2 +- contract-tests/runners/ts/package-lock.json | 43 +++++++++++++----- contract-tests/runners/ts/package.json | 3 ++ examples/basic/contacts/go.mod | 2 +- examples/basic/notes/go.mod | 2 +- examples/basic/todo/go.mod | 2 +- examples/multi-tenant/go.mod | 2 +- go.mod | 2 +- ts/package-lock.json | 49 ++++++++++++++------- ts/package.json | 4 ++ 10 files changed, 78 insertions(+), 33 deletions(-) diff --git a/contract-tests/runners/go/go.mod b/contract-tests/runners/go/go.mod index ac8f755..d1dfe91 100644 --- a/contract-tests/runners/go/go.mod +++ b/contract-tests/runners/go/go.mod @@ -2,7 +2,7 @@ module github.com/theory-cloud/tabletheory-contract-tests/runners/go go 1.26 -toolchain go1.26.0 +toolchain go1.26.1 require ( github.com/aws/aws-sdk-go-v2 v1.41.3 diff --git a/contract-tests/runners/ts/package-lock.json b/contract-tests/runners/ts/package-lock.json index 6009cd8..6d0b09c 100644 --- a/contract-tests/runners/ts/package-lock.json +++ b/contract-tests/runners/ts/package-lock.json @@ -1752,21 +1752,24 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", "funding": [ { "type": "github", @@ -1775,8 +1778,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -1825,6 +1829,21 @@ "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "license": "MIT" }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1836,9 +1855,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", diff --git a/contract-tests/runners/ts/package.json b/contract-tests/runners/ts/package.json index 6beee6e..f01e3cb 100644 --- a/contract-tests/runners/ts/package.json +++ b/contract-tests/runners/ts/package.json @@ -16,6 +16,9 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.1002.0" }, + "overrides": { + "fast-xml-parser": "^5.5.7" + }, "devDependencies": { "tsx": "^4.21.0", "yaml": "^2.8.2" diff --git a/examples/basic/contacts/go.mod b/examples/basic/contacts/go.mod index 2add090..61dd8e4 100644 --- a/examples/basic/contacts/go.mod +++ b/examples/basic/contacts/go.mod @@ -2,7 +2,7 @@ module github.com/theory-cloud/tabletheory/examples/basic/contacts go 1.26 -toolchain go1.26.0 +toolchain go1.26.1 require ( github.com/aws/aws-sdk-go-v2/config v1.32.11 diff --git a/examples/basic/notes/go.mod b/examples/basic/notes/go.mod index d538c46..7eedc78 100644 --- a/examples/basic/notes/go.mod +++ b/examples/basic/notes/go.mod @@ -2,7 +2,7 @@ module github.com/theory-cloud/tabletheory/examples/basic/notes go 1.26 -toolchain go1.26.0 +toolchain go1.26.1 require ( github.com/aws/aws-sdk-go-v2/config v1.32.11 diff --git a/examples/basic/todo/go.mod b/examples/basic/todo/go.mod index 628c06f..8543f88 100644 --- a/examples/basic/todo/go.mod +++ b/examples/basic/todo/go.mod @@ -2,7 +2,7 @@ module github.com/theory-cloud/tabletheory/examples/basic/todo go 1.26 -toolchain go1.26.0 +toolchain go1.26.1 require ( github.com/aws/aws-sdk-go-v2/config v1.32.11 diff --git a/examples/multi-tenant/go.mod b/examples/multi-tenant/go.mod index b74c396..345867a 100644 --- a/examples/multi-tenant/go.mod +++ b/examples/multi-tenant/go.mod @@ -2,7 +2,7 @@ module github.com/theory-cloud/tabletheory/examples/multi-tenant go 1.26 -toolchain go1.26.0 +toolchain go1.26.1 require ( github.com/google/uuid v1.6.0 diff --git a/go.mod b/go.mod index c1a0230..49fe5f9 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/theory-cloud/tabletheory go 1.26 -toolchain go1.26.0 +toolchain go1.26.1 require ( github.com/aws/aws-lambda-go v1.53.0 diff --git a/ts/package-lock.json b/ts/package-lock.json index f058924..7b71ea6 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -2623,21 +2623,24 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", "funding": [ { "type": "github", @@ -2646,8 +2649,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -2716,9 +2720,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2992,6 +2996,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3098,9 +3117,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", diff --git a/ts/package.json b/ts/package.json index 1b595ff..6b5c35a 100644 --- a/ts/package.json +++ b/ts/package.json @@ -37,6 +37,10 @@ "@aws-sdk/client-sts": "^3.1002.0", "yaml": "^2.8.2" }, + "overrides": { + "fast-xml-parser": "^5.5.7", + "flatted": "^3.4.2" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.11.0", From 2f2fea5d8aa93ea2dd8d9973435ddd3fcbdf25bf Mon Sep 17 00:00:00 2001 From: Aron Price Date: Mon, 23 Mar 2026 15:21:26 -0400 Subject: [PATCH 3/5] test: restore rubric coverage gate stability --- Makefile | 4 +- pkg/schema/ttl_test.go | 55 +++++++++++++++++++++ py/tests/unit/test_schema.py | 92 ++++++++++++++++++++++++++++++++++++ scripts/coverage.sh | 5 +- 4 files changed, 152 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index dedb102..6dcd5e4 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,8 @@ GOMOD := github.com/theory-cloud/tabletheory TOOLCHAIN := $(shell awk '/^toolchain / {print $$2}' go.mod | head -n 1) export GOTOOLCHAIN ?= $(TOOLCHAIN) -UNIT_PACKAGES := $(shell go list ./... | grep -v /vendor/ | grep -v /examples/ | grep -v /tests/stress | grep -v /tests/integration) -ALL_PACKAGES := $(shell go list ./... | grep -v /vendor/ | grep -v /examples/ | grep -v /tests/stress) +UNIT_PACKAGES := $(shell go list ./... | grep -v /vendor/ | grep -v /node_modules/ | grep -v /examples/ | grep -v /tests/stress | grep -v /tests/integration) +ALL_PACKAGES := $(shell go list ./... | grep -v /vendor/ | grep -v /node_modules/ | grep -v /examples/ | grep -v /tests/stress) INTEGRATION_PACKAGES := $(shell go list ./tests/integration/...) DYNAMODB_LOCAL_IMAGE ?= amazon/dynamodb-local:3.1.0 diff --git a/pkg/schema/ttl_test.go b/pkg/schema/ttl_test.go index ea5945c..f9b9cc2 100644 --- a/pkg/schema/ttl_test.go +++ b/pkg/schema/ttl_test.go @@ -1,6 +1,7 @@ package schema import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -91,6 +92,60 @@ func TestManager_EnableTTL_ValidatesTTLField_COV6(t *testing.T) { require.ErrorContains(t, err, "does not define a ttl field") } +func TestManager_EnableTTL_Success_COV6(t *testing.T) { + httpClient := newCapturingHTTPClient(nil) + httpClient.SetResponseSequence("DynamoDB_20120810.UpdateTimeToLive", []stubbedResponse{ + {body: `{"TimeToLiveSpecification":{"AttributeName":"expiresAt","Enabled":true}}`}, + }) + + mgr := newTestManager(t, httpClient) + require.NoError(t, mgr.registry.Register(&ttlSchemaModel{})) + + require.NoError(t, mgr.EnableTTL(&ttlSchemaModel{})) + + reqs := httpClient.Requests() + require.Equal(t, 1, countRequestsByTarget(reqs, "DynamoDB_20120810.UpdateTimeToLive")) + require.Equal(t, map[string]any{ + "AttributeName": "expiresAt", + "Enabled": true, + }, reqs[len(reqs)-1].Payload["TimeToLiveSpecification"]) +} + +func TestManager_EnableTTL_WrapsMetadataAndClientErrors_COV6(t *testing.T) { + t.Run("metadata lookup errors are wrapped", func(t *testing.T) { + mgr := &Manager{registry: model.NewRegistry()} + + err := mgr.EnableTTL(&ttlSchemaModel{}) + require.ErrorContains(t, err, "failed to get model metadata") + }) + + t.Run("client errors are wrapped", func(t *testing.T) { + mgr := &Manager{registry: modelRegistryWith(t, &ttlSchemaModel{})} + + err := mgr.EnableTTL(&ttlSchemaModel{}) + require.ErrorContains(t, err, "failed to get client for ttl update") + }) +} + +func TestManager_SyncModelTTL_WrapsUpdateErrors_COV6(t *testing.T) { + httpClient := newCapturingHTTPClient(nil) + httpClient.SetResponseSequence("DynamoDB_20120810.UpdateTimeToLive", []stubbedResponse{ + stubbedAWSError("ValidationException", "boom"), + }) + + mgr := newTestManager(t, httpClient) + require.NoError(t, mgr.registry.Register(&ttlSchemaModel{})) + + client, err := mgr.session.Client() + require.NoError(t, err) + + metadata, err := mgr.registry.GetMetadata(&ttlSchemaModel{}) + require.NoError(t, err) + + err = mgr.syncModelTTL(context.Background(), client, metadata) + require.ErrorContains(t, err, "failed to enable ttl on table ttl_records") +} + func modelRegistryWith(t *testing.T, values ...any) *model.Registry { t.Helper() diff --git a/py/tests/unit/test_schema.py b/py/tests/unit/test_schema.py index f63a943..ee9ceb0 100644 --- a/py/tests/unit/test_schema.py +++ b/py/tests/unit/test_schema.py @@ -5,6 +5,7 @@ import pytest from botocore.exceptions import ClientError +from theorydb_py import schema as schema_module from theorydb_py import ModelDefinition, Projection, ValidationError, gsi, lsi, theorydb_field from theorydb_py.errors import AwsError, NotFoundError from theorydb_py.mocks import FakeDynamoDBClient @@ -331,3 +332,94 @@ class UnsupportedKey: bad_model = ModelDefinition.from_dataclass(UnsupportedKey, table_name="tbl_bad") with pytest.raises(ValidationError, match="key attribute must be S/N/B"): build_create_table_request(bad_model) + + +def test_schema_helpers_use_default_boto3_client( + monkeypatch: pytest.MonkeyPatch, + model: ModelDefinition[object], + ttl_model: ModelDefinition[object], +) -> None: + create_client = FakeDynamoDBClient() + create_client.expect("create_table", {"TableName": "tbl", "BillingMode": "PAY_PER_REQUEST"}, response={}) + + ensure_client = FakeDynamoDBClient() + ensure_client.expect("describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + ensure_client.expect("describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + ensure_client.expect( + "update_time_to_live", + { + "TableName": "ttl_tbl", + "TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}, + }, + response={"TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}}, + ) + + delete_client = FakeDynamoDBClient() + delete_client.expect("delete_table", {"TableName": "tbl"}, response={}) + + describe_client = FakeDynamoDBClient() + describe_client.expect("describe_table", {"TableName": "tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + + ttl_client = FakeDynamoDBClient() + ttl_client.expect( + "update_time_to_live", + { + "TableName": "ttl_tbl", + "TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}, + }, + response={"TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}}, + ) + + clients = iter([create_client, ensure_client, delete_client, describe_client, ttl_client]) + monkeypatch.setattr(schema_module.boto3, "client", lambda service: next(clients)) + + create_table(model, wait_for_active=False) + ensure_table(ttl_model, wait_for_active=False, sleep=lambda _: None) + delete_table(model, wait_for_delete=False) + assert describe_table(model)["Table"]["TableStatus"] == "ACTIVE" + update_time_to_live(ttl_model) + + create_client.assert_no_pending() + ensure_client.assert_no_pending() + delete_client.assert_no_pending() + describe_client.assert_no_pending() + ttl_client.assert_no_pending() + + +def test_update_time_to_live_noops_without_ttl_attribute(model: ModelDefinition[object]) -> None: + client = FakeDynamoDBClient() + + update_time_to_live(model, client=client) + + assert client.calls == [] + + +def test_update_time_to_live_maps_validation_exception(ttl_model: ModelDefinition[object]) -> None: + client = FakeDynamoDBClient() + client.expect( + "update_time_to_live", + { + "TableName": "ttl_tbl", + "TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}, + }, + error=ClientError({"Error": {"Code": "ValidationException", "Message": "bad ttl"}}, "UpdateTimeToLive"), + ) + + with pytest.raises(ValidationError, match="bad ttl"): + update_time_to_live(ttl_model, client=client) + + +def test_schema_operations_require_table_name_when_model_has_none(model: ModelDefinition[object]) -> None: + nameless = replace(model, table_name="") + client = FakeDynamoDBClient() + + with pytest.raises(ValueError, match="table_name is required"): + build_create_table_request(nameless) + with pytest.raises(ValueError, match="table_name is required"): + ensure_table(nameless, client=client) + with pytest.raises(ValueError, match="table_name is required"): + delete_table(nameless, client=client) + with pytest.raises(ValueError, match="table_name is required"): + describe_table(nameless, client=client) + with pytest.raises(ValueError, match="table_name is required"): + update_time_to_live(nameless, client=client) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index c81177d..21c3edb 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -3,9 +3,10 @@ set -euo pipefail profile="${1:-coverage_lib.out}" -# Measure "library coverage" (exclude repo-local examples, tests, and tool harness packages). +# Measure "library coverage" (exclude repo-local examples, tests, tool harness packages, +# and third-party dependency trees materialized inside the repo). # This avoids a low-signal denominator dominated by non-library modules. -pkgs="$(go list ./... | grep -Ev '/examples($|/)|/tests($|/)|/scripts($|/)')" +pkgs="$(go list ./... | grep -Ev '/examples($|/)|/tests($|/)|/scripts($|/)|/node_modules($|/)|/vendor($|/)')" if [[ -z "${pkgs}" ]]; then echo "no packages found" exit 1 From 35fcd7eff4ca49ff2d0effaf4acd892d4fdcbd0f Mon Sep 17 00:00:00 2001 From: Aron Price Date: Mon, 23 Mar 2026 15:24:15 -0400 Subject: [PATCH 4/5] style: fix python schema test formatting --- py/tests/unit/test_schema.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/py/tests/unit/test_schema.py b/py/tests/unit/test_schema.py index ee9ceb0..bb6752a 100644 --- a/py/tests/unit/test_schema.py +++ b/py/tests/unit/test_schema.py @@ -5,8 +5,8 @@ import pytest from botocore.exceptions import ClientError -from theorydb_py import schema as schema_module from theorydb_py import ModelDefinition, Projection, ValidationError, gsi, lsi, theorydb_field +from theorydb_py import schema as schema_module from theorydb_py.errors import AwsError, NotFoundError from theorydb_py.mocks import FakeDynamoDBClient from theorydb_py.schema import ( @@ -343,8 +343,12 @@ def test_schema_helpers_use_default_boto3_client( create_client.expect("create_table", {"TableName": "tbl", "BillingMode": "PAY_PER_REQUEST"}, response={}) ensure_client = FakeDynamoDBClient() - ensure_client.expect("describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) - ensure_client.expect("describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + ensure_client.expect( + "describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}} + ) + ensure_client.expect( + "describe_table", {"TableName": "ttl_tbl"}, response={"Table": {"TableStatus": "ACTIVE"}} + ) ensure_client.expect( "update_time_to_live", { @@ -358,7 +362,9 @@ def test_schema_helpers_use_default_boto3_client( delete_client.expect("delete_table", {"TableName": "tbl"}, response={}) describe_client = FakeDynamoDBClient() - describe_client.expect("describe_table", {"TableName": "tbl"}, response={"Table": {"TableStatus": "ACTIVE"}}) + describe_client.expect( + "describe_table", {"TableName": "tbl"}, response={"Table": {"TableStatus": "ACTIVE"}} + ) ttl_client = FakeDynamoDBClient() ttl_client.expect( @@ -402,7 +408,9 @@ def test_update_time_to_live_maps_validation_exception(ttl_model: ModelDefinitio "TableName": "ttl_tbl", "TimeToLiveSpecification": {"AttributeName": "expires_at", "Enabled": True}, }, - error=ClientError({"Error": {"Code": "ValidationException", "Message": "bad ttl"}}, "UpdateTimeToLive"), + error=ClientError( + {"Error": {"Code": "ValidationException", "Message": "bad ttl"}}, "UpdateTimeToLive" + ), ) with pytest.raises(ValidationError, match="bad ttl"): From dc8f34f1eac36838c3b02b54681e7862a395837d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:36:33 +0000 Subject: [PATCH 5/5] chore(premain): release 1.5.0-rc --- .release-please-manifest.premain.json | 2 +- CHANGELOG.md | 13 +++++++++++++ py/src/theorydb_py/version.json | 2 +- ts/package-lock.json | 4 ++-- ts/package.json | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.premain.json b/.release-please-manifest.premain.json index efe9bfb..8a031c5 100644 --- a/.release-please-manifest.premain.json +++ b/.release-please-manifest.premain.json @@ -1,3 +1,3 @@ { - ".": "1.4.2" + ".": "1.5.0-rc" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b7f895..508516d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ * add TTL-aware schema provisioning across Go, TypeScript, and Python helpers * add CDK archival construct for DynamoDB TTL expirations to S3 Glacier lifecycle storage +## [1.5.0-rc](https://github.com/theory-cloud/TableTheory/compare/v1.4.2...v1.5.0-rc) (2026-03-23) + + +### Features + +* add TTL archival lifecycle support ([0f1b88d](https://github.com/theory-cloud/TableTheory/commit/0f1b88d7012ea3436964328a73978ff680137f94)) +* add TTL archival lifecycle support ([22ffedc](https://github.com/theory-cloud/TableTheory/commit/22ffedcfccff7f4732eaad50473a89bcffd7815e)) + + +### Bug Fixes + +* remediate rubric dependency scan failures ([0c6b9ee](https://github.com/theory-cloud/TableTheory/commit/0c6b9eec7d38719af075bb09a7908d0a97556ee8)) + ## [1.4.2](https://github.com/theory-cloud/TableTheory/compare/v1.4.1...v1.4.2) (2026-03-05) diff --git a/py/src/theorydb_py/version.json b/py/src/theorydb_py/version.json index f0d7d7f..f1ebae8 100644 --- a/py/src/theorydb_py/version.json +++ b/py/src/theorydb_py/version.json @@ -1,3 +1,3 @@ { - "version": "1.4.2" + "version": "1.5.0-rc" } diff --git a/ts/package-lock.json b/ts/package-lock.json index 5619da6..b6ef84e 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@theory-cloud/tabletheory-ts", - "version": "1.4.2", + "version": "1.5.0-rc", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@theory-cloud/tabletheory-ts", - "version": "1.4.2", + "version": "1.5.0-rc", "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-dynamodb": "^3.1002.0", diff --git a/ts/package.json b/ts/package.json index 85971a0..cff6d7d 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@theory-cloud/tabletheory-ts", - "version": "1.4.2", + "version": "1.5.0-rc", "license": "Apache-2.0", "private": true, "type": "module",