@aws-blocks/bb-distributed-data 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +174 -0
- package/README.md +243 -0
- package/dist/constants.d.ts +38 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +50 -0
- package/dist/e2e-mock.test.d.ts +2 -0
- package/dist/e2e-mock.test.d.ts.map +1 -0
- package/dist/e2e-mock.test.js +185 -0
- package/dist/e2e.test.d.ts +2 -0
- package/dist/e2e.test.d.ts.map +1 -0
- package/dist/e2e.test.js +329 -0
- package/dist/engines/dsql-engine.d.ts +26 -0
- package/dist/engines/dsql-engine.d.ts.map +1 -0
- package/dist/engines/dsql-engine.js +103 -0
- package/dist/engines/dsql-mock-engine.d.ts +28 -0
- package/dist/engines/dsql-mock-engine.d.ts.map +1 -0
- package/dist/engines/dsql-mock-engine.js +132 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +45 -0
- package/dist/errors.test.d.ts +2 -0
- package/dist/errors.test.d.ts.map +1 -0
- package/dist/errors.test.js +55 -0
- package/dist/index.aws.d.ts +35 -0
- package/dist/index.aws.d.ts.map +1 -0
- package/dist/index.aws.js +59 -0
- package/dist/index.browser.d.ts +5 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +7 -0
- package/dist/index.cdk.d.ts +16 -0
- package/dist/index.cdk.d.ts.map +1 -0
- package/dist/index.cdk.js +103 -0
- package/dist/index.cdk.test.d.ts +2 -0
- package/dist/index.cdk.test.d.ts.map +1 -0
- package/dist/index.cdk.test.js +182 -0
- package/dist/index.mock.d.ts +39 -0
- package/dist/index.mock.d.ts.map +1 -0
- package/dist/index.mock.js +53 -0
- package/dist/migration-lambda.d.ts +5 -0
- package/dist/migration-lambda.d.ts.map +1 -0
- package/dist/migration-lambda.js +139 -0
- package/dist/migrations.d.ts +11 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +59 -0
- package/dist/transaction.d.ts +12 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +24 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/validation.d.ts +18 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +169 -0
- package/dist/validation.test.d.ts +2 -0
- package/dist/validation.test.d.ts.map +1 -0
- package/dist/validation.test.js +354 -0
- package/package.json +48 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* CDK construct tests for DistributedDatabase.
|
|
5
|
+
* Pattern follows bb-auth-cognito/src/index.cdk.test.ts — sets up a plain Stack
|
|
6
|
+
* with a placeholder handler on globalThis to satisfy Scope.handler lookups.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import * as cdk from 'aws-cdk-lib';
|
|
11
|
+
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
12
|
+
import { Template, Match } from 'aws-cdk-lib/assertions';
|
|
13
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
16
|
+
import { DistributedDatabase } from './index.cdk.js';
|
|
17
|
+
const MIGRATIONS_DIR = '.bb-data/__test_cdk_migrations__';
|
|
18
|
+
function synth(build) {
|
|
19
|
+
const app = new cdk.App();
|
|
20
|
+
const stack = new cdk.Stack(app, 'TestStack');
|
|
21
|
+
const handler = new lambda.Function(stack, 'Handler', {
|
|
22
|
+
runtime: DEFAULT_NODE_RUNTIME,
|
|
23
|
+
handler: 'index.handler',
|
|
24
|
+
code: lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
25
|
+
});
|
|
26
|
+
stack.handler = handler;
|
|
27
|
+
globalThis.CURRENT_BLOCKS_STACK = stack;
|
|
28
|
+
try {
|
|
29
|
+
build(stack);
|
|
30
|
+
return Template.fromStack(stack);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
delete globalThis.CURRENT_BLOCKS_STACK;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function scope(stack) {
|
|
37
|
+
return stack;
|
|
38
|
+
}
|
|
39
|
+
// --- DSQL Cluster ---
|
|
40
|
+
test('CDK: synthesized stack contains AWS::DSQL::Cluster', () => {
|
|
41
|
+
const template = synth((stack) => {
|
|
42
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
43
|
+
});
|
|
44
|
+
template.resourceCountIs('AWS::DSQL::Cluster', 1);
|
|
45
|
+
});
|
|
46
|
+
test('CDK: cluster has DeletionProtectionEnabled=true by default', () => {
|
|
47
|
+
const template = synth((stack) => {
|
|
48
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
49
|
+
});
|
|
50
|
+
template.hasResource('AWS::DSQL::Cluster', {
|
|
51
|
+
Properties: { DeletionProtectionEnabled: true },
|
|
52
|
+
DeletionPolicy: 'Retain',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
test('CDK: removalPolicy=destroy disables deletion protection', () => {
|
|
56
|
+
const template = synth((stack) => {
|
|
57
|
+
new DistributedDatabase(scope(stack), 'mydsql', { removalPolicy: 'destroy' });
|
|
58
|
+
});
|
|
59
|
+
template.hasResource('AWS::DSQL::Cluster', {
|
|
60
|
+
Properties: { DeletionProtectionEnabled: false },
|
|
61
|
+
DeletionPolicy: 'Delete',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
test('CDK: sandboxMode=true disables deletion protection', () => {
|
|
65
|
+
const app = new cdk.App({ context: { sandboxMode: 'true' } });
|
|
66
|
+
const stack = new cdk.Stack(app, 'SandboxStack');
|
|
67
|
+
const handler = new lambda.Function(stack, 'Handler', {
|
|
68
|
+
runtime: DEFAULT_NODE_RUNTIME,
|
|
69
|
+
handler: 'index.handler',
|
|
70
|
+
code: lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
71
|
+
});
|
|
72
|
+
stack.handler = handler;
|
|
73
|
+
globalThis.CURRENT_BLOCKS_STACK = stack;
|
|
74
|
+
try {
|
|
75
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
76
|
+
const template = Template.fromStack(stack);
|
|
77
|
+
template.hasResource('AWS::DSQL::Cluster', {
|
|
78
|
+
Properties: { DeletionProtectionEnabled: false },
|
|
79
|
+
DeletionPolicy: 'Delete',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
delete globalThis.CURRENT_BLOCKS_STACK;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// --- IAM ---
|
|
87
|
+
test('CDK: handler gets dsql:DbConnect policy (least privilege)', () => {
|
|
88
|
+
const template = synth((stack) => {
|
|
89
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
90
|
+
});
|
|
91
|
+
template.hasResourceProperties('AWS::IAM::Policy', {
|
|
92
|
+
PolicyDocument: {
|
|
93
|
+
Statement: Match.arrayWith([
|
|
94
|
+
Match.objectLike({
|
|
95
|
+
Action: 'dsql:DbConnect',
|
|
96
|
+
Effect: 'Allow',
|
|
97
|
+
}),
|
|
98
|
+
]),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
// --- Environment variables ---
|
|
103
|
+
test('CDK: handler gets ENDPOINT and REGION env vars', () => {
|
|
104
|
+
const template = synth((stack) => {
|
|
105
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
106
|
+
});
|
|
107
|
+
const fns = template.findResources('AWS::Lambda::Function');
|
|
108
|
+
const handlerFn = Object.entries(fns).find(([id]) => id.startsWith('Handler'));
|
|
109
|
+
assert.ok(handlerFn, 'Handler Lambda should exist');
|
|
110
|
+
const env = handlerFn[1].Properties?.Environment?.Variables ?? {};
|
|
111
|
+
const envKeys = Object.keys(env);
|
|
112
|
+
assert.ok(envKeys.some(k => k.includes('ENDPOINT')), `Expected ENDPOINT env var, got: ${envKeys}`);
|
|
113
|
+
assert.ok(envKeys.some(k => k.includes('REGION')), `Expected REGION env var, got: ${envKeys}`);
|
|
114
|
+
});
|
|
115
|
+
// --- CfnOutput ---
|
|
116
|
+
test('CDK: stack has endpoint output', () => {
|
|
117
|
+
const template = synth((stack) => {
|
|
118
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
119
|
+
});
|
|
120
|
+
const outputs = template.findOutputs('*');
|
|
121
|
+
const outputKeys = Object.keys(outputs);
|
|
122
|
+
assert.ok(outputKeys.some(k => k.includes('DsqlEndpoint')), `Expected DsqlEndpoint output, got: ${outputKeys}`);
|
|
123
|
+
});
|
|
124
|
+
// --- Migrations ---
|
|
125
|
+
test('CDK: migration/provisioning resources always created', () => {
|
|
126
|
+
const template = synth((stack) => {
|
|
127
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
128
|
+
});
|
|
129
|
+
// The provisioning CustomResource is always created (for DB role setup)
|
|
130
|
+
const customResources = template.findResources('AWS::CloudFormation::CustomResource');
|
|
131
|
+
assert.ok(Object.keys(customResources).length > 0, 'Should have provisioning CustomResource');
|
|
132
|
+
// Migration Lambda should have dsql:DbConnectAdmin for role management
|
|
133
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
134
|
+
const policyValues = Object.values(policies);
|
|
135
|
+
const hasDsqlGrant = policyValues.some((p) => JSON.stringify(p).includes('dsql:DbConnectAdmin'));
|
|
136
|
+
assert.ok(hasDsqlGrant, 'Migration Lambda should have dsql:DbConnectAdmin');
|
|
137
|
+
});
|
|
138
|
+
test('CDK: migration resources created when migrationsPath is provided', () => {
|
|
139
|
+
rmSync(MIGRATIONS_DIR, { recursive: true, force: true });
|
|
140
|
+
mkdirSync(MIGRATIONS_DIR, { recursive: true });
|
|
141
|
+
writeFileSync(join(MIGRATIONS_DIR, '001_create.sql'), 'CREATE TABLE t (id TEXT PRIMARY KEY)');
|
|
142
|
+
try {
|
|
143
|
+
const template = synth((stack) => {
|
|
144
|
+
new DistributedDatabase(scope(stack), 'mydsql', { migrationsPath: MIGRATIONS_DIR });
|
|
145
|
+
});
|
|
146
|
+
const customResources = template.findResources('AWS::CloudFormation::CustomResource');
|
|
147
|
+
assert.ok(Object.keys(customResources).length > 0, 'Should have migration CustomResource');
|
|
148
|
+
// Migration Lambda should have dsql:DbConnectAdmin
|
|
149
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
150
|
+
const policyValues = Object.values(policies);
|
|
151
|
+
const hasDsqlGrant = policyValues.some((p) => JSON.stringify(p).includes('dsql:DbConnectAdmin'));
|
|
152
|
+
assert.ok(hasDsqlGrant, 'Migration Lambda should have dsql:DbConnectAdmin');
|
|
153
|
+
// Migration Lambda should have APP_ROLE_ARN and DB_ROLE_NAME env vars
|
|
154
|
+
const fns = template.findResources('AWS::Lambda::Function');
|
|
155
|
+
const migrationFn = Object.entries(fns).find(([id]) => id.includes('MigrationFn'));
|
|
156
|
+
assert.ok(migrationFn, 'Migration Lambda should exist');
|
|
157
|
+
const env = migrationFn[1].Properties?.Environment?.Variables ?? {};
|
|
158
|
+
assert.ok(env.APP_ROLE_ARN, 'Migration Lambda should have APP_ROLE_ARN env var');
|
|
159
|
+
assert.ok(env.DB_ROLE_NAME, 'Migration Lambda should have DB_ROLE_NAME env var');
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
rmSync(MIGRATIONS_DIR, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
test('CDK: migration CustomResource has migrationsHash property', () => {
|
|
166
|
+
rmSync(MIGRATIONS_DIR, { recursive: true, force: true });
|
|
167
|
+
mkdirSync(MIGRATIONS_DIR, { recursive: true });
|
|
168
|
+
writeFileSync(join(MIGRATIONS_DIR, '001_create.sql'), 'CREATE TABLE t (id TEXT PRIMARY KEY)');
|
|
169
|
+
try {
|
|
170
|
+
const template = synth((stack) => {
|
|
171
|
+
new DistributedDatabase(scope(stack), 'mydsql', { migrationsPath: MIGRATIONS_DIR });
|
|
172
|
+
});
|
|
173
|
+
const customResources = template.findResources('AWS::CloudFormation::CustomResource');
|
|
174
|
+
const cr = Object.values(customResources)[0];
|
|
175
|
+
assert.ok(cr.Properties?.migrationsHash, 'CustomResource should have migrationsHash property');
|
|
176
|
+
assert.strictEqual(typeof cr.Properties.migrationsHash, 'string');
|
|
177
|
+
assert.strictEqual(cr.Properties.migrationsHash.length, 16);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
rmSync(MIGRATIONS_DIR, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DistributedDatabase — Local development entry point.
|
|
3
|
+
* PGlite + DSQL validation layer.
|
|
4
|
+
*/
|
|
5
|
+
import { Scope } from '@aws-blocks/core';
|
|
6
|
+
import type { ScopeParent } from '@aws-blocks/core';
|
|
7
|
+
import { type SqlQuery, type Transaction } from '@aws-blocks/data-common';
|
|
8
|
+
import type { DistributedDatabaseOptions, TransactionOptions } from './types.js';
|
|
9
|
+
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
10
|
+
export declare class DistributedDatabase extends Scope {
|
|
11
|
+
private base;
|
|
12
|
+
private mockEngine;
|
|
13
|
+
private migrationsRun;
|
|
14
|
+
/** @internal Logger for internal operations. Defaults to error-level when not provided. */
|
|
15
|
+
protected log: ChildLogger;
|
|
16
|
+
constructor(scope: ScopeParent, id: string, options?: DistributedDatabaseOptions);
|
|
17
|
+
private ready;
|
|
18
|
+
query<T>(query: SqlQuery): Promise<T[]>;
|
|
19
|
+
queryOne<T>(query: SqlQuery): Promise<T | null>;
|
|
20
|
+
execute(query: SqlQuery): Promise<{
|
|
21
|
+
rowCount: number;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Execute a function within a transaction with optional OCC retry.
|
|
25
|
+
*
|
|
26
|
+
* DSQL uses Optimistic Concurrency Control. Commit may fail with
|
|
27
|
+
* SerializationFailureException if another transaction modified the same rows.
|
|
28
|
+
*/
|
|
29
|
+
transaction<T>(fn: (tx: Transaction) => Promise<T>, options?: TransactionOptions): Promise<T>;
|
|
30
|
+
/** Test helper: simulate OCC conflict on next commit. */
|
|
31
|
+
simulateConflict(): void;
|
|
32
|
+
/** @internal */
|
|
33
|
+
getEngine(): import("@aws-blocks/data-common").DatabaseEngine;
|
|
34
|
+
}
|
|
35
|
+
export { sql, createKyselyAdapter } from '@aws-blocks/data-common';
|
|
36
|
+
export type { SqlQuery, Transaction } from '@aws-blocks/data-common';
|
|
37
|
+
export { DistributedDatabaseErrors } from './errors.js';
|
|
38
|
+
export type { DistributedDatabaseOptions, TransactionOptions } from './types.js';
|
|
39
|
+
//# sourceMappingURL=index.mock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"AAGA;;;GAGG;AAEH,OAAO,EAAE,KAAK,EAA0B,MAAM,kBAAkB,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAgB,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAIxF,OAAO,KAAK,EAAE,0BAA0B,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAEjF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,OAAO,CAAC,IAAI,CAAe;IAC3B,OAAO,CAAC,UAAU,CAAiB;IACnC,OAAO,CAAC,aAAa,CAA8B;IAEnD,2FAA2F;IAC3F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,0BAA0B;YAelE,KAAK;IAEnB,KAAK,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IACvC,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAC/C,OAAO,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAEvD;;;;;OAKG;IACG,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,CAAC,CAAC;IAKnG,yDAAyD;IACzD,gBAAgB,IAAI,IAAI;IAExB,gBAAgB;IAChB,SAAS;CACV;AAED,OAAO,EAAE,GAAG,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AACnE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACrE,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACxD,YAAY,EAAE,0BAA0B,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* DistributedDatabase — Local development entry point.
|
|
5
|
+
* PGlite + DSQL validation layer.
|
|
6
|
+
*/
|
|
7
|
+
import { Scope, registerSdkIdentifiers } from '@aws-blocks/core';
|
|
8
|
+
import { DatabaseBase } from '@aws-blocks/data-common';
|
|
9
|
+
import { DsqlMockEngine } from './engines/dsql-mock-engine.js';
|
|
10
|
+
import { runMigrations, loadMigrationsFromDir } from './migrations.js';
|
|
11
|
+
import { transactionWithRetry } from './transaction.js';
|
|
12
|
+
import { Logger } from '@aws-blocks/bb-logger';
|
|
13
|
+
export class DistributedDatabase extends Scope {
|
|
14
|
+
base;
|
|
15
|
+
mockEngine;
|
|
16
|
+
migrationsRun = null;
|
|
17
|
+
/** @internal Logger for internal operations. Defaults to error-level when not provided. */
|
|
18
|
+
log;
|
|
19
|
+
constructor(scope, id, options) {
|
|
20
|
+
super(id, { parent: scope });
|
|
21
|
+
this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
|
|
22
|
+
this.mockEngine = new DsqlMockEngine(`.bb-data/${this.fullId}`);
|
|
23
|
+
this.base = new DatabaseBase(this.mockEngine);
|
|
24
|
+
registerSdkIdentifiers(this.fullId, { clusterEndpoint: `mock-endpoint-${this.fullId}` });
|
|
25
|
+
if (options?.migrationsPath) {
|
|
26
|
+
const path = options.migrationsPath;
|
|
27
|
+
this.migrationsRun = loadMigrationsFromDir(path)
|
|
28
|
+
.then(m => this.mockEngine.withDdl(() => runMigrations(this.mockEngine, m)))
|
|
29
|
+
.then(() => { });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async ready() { if (this.migrationsRun)
|
|
33
|
+
await this.migrationsRun; }
|
|
34
|
+
query(query) { return this.ready().then(() => this.base.query(query)); }
|
|
35
|
+
queryOne(query) { return this.ready().then(() => this.base.queryOne(query)); }
|
|
36
|
+
execute(query) { return this.ready().then(() => this.base.execute(query)); }
|
|
37
|
+
/**
|
|
38
|
+
* Execute a function within a transaction with optional OCC retry.
|
|
39
|
+
*
|
|
40
|
+
* DSQL uses Optimistic Concurrency Control. Commit may fail with
|
|
41
|
+
* SerializationFailureException if another transaction modified the same rows.
|
|
42
|
+
*/
|
|
43
|
+
async transaction(fn, options) {
|
|
44
|
+
await this.ready();
|
|
45
|
+
return transactionWithRetry(this.base, fn, options);
|
|
46
|
+
}
|
|
47
|
+
/** Test helper: simulate OCC conflict on next commit. */
|
|
48
|
+
simulateConflict() { this.mockEngine.simulateConflict(); }
|
|
49
|
+
/** @internal */
|
|
50
|
+
getEngine() { return this.base.getEngine(); }
|
|
51
|
+
}
|
|
52
|
+
export { sql, createKyselyAdapter } from '@aws-blocks/data-common';
|
|
53
|
+
export { DistributedDatabaseErrors } from './errors.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migration-lambda.d.ts","sourceRoot":"","sources":["../src/migration-lambda.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,iCAAiC,EAAE,MAAM,YAAY,CAAC;AA0FpE,eAAO,MAAM,OAAO,GAAU,OAAO,iCAAiC,KAAG,OAAO,CAAC;IAAE,kBAAkB,EAAE,MAAM,CAAA;CAAE,CAoD9G,CAAC"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* CloudFormation custom resource handler that runs DSQL migrations on deploy.
|
|
5
|
+
*
|
|
6
|
+
* Migrations are bundled as `.sql` files in the Lambda deployment package
|
|
7
|
+
* (at MIGRATIONS_DIR). The CFN resource property `migrationsHash` triggers
|
|
8
|
+
* re-invocation when migration files change.
|
|
9
|
+
*
|
|
10
|
+
* After running migrations, this handler also provisions a least-privilege
|
|
11
|
+
* database role for the app Lambda and maps it to the app's IAM role ARN.
|
|
12
|
+
*
|
|
13
|
+
* Connects to DSQL via pg + IAM token auth (DsqlSigner) as admin.
|
|
14
|
+
*/
|
|
15
|
+
import { DsqlSigner } from '@aws-sdk/dsql-signer';
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { DsqlEngine } from './engines/dsql-engine.js';
|
|
18
|
+
import { runMigrations, loadMigrationsFromDir } from './migrations.js';
|
|
19
|
+
import { LAMBDA_MIGRATIONS_DIR } from './constants.js';
|
|
20
|
+
// Set by the CDK construct's environment config. Default is the Lambda deployment root
|
|
21
|
+
// where CDK's afterBundling hook copies the .sql files.
|
|
22
|
+
const MIGRATIONS_DIR = process.env.MIGRATIONS_DIR || LAMBDA_MIGRATIONS_DIR;
|
|
23
|
+
const MAX_RETRIES = 5;
|
|
24
|
+
const INITIAL_DELAY_MS = 2000;
|
|
25
|
+
const MAX_DELAY_MS = 15000;
|
|
26
|
+
/**
|
|
27
|
+
* Retry with exponential backoff for transient connection errors.
|
|
28
|
+
* DSQL clusters may take a moment to accept connections after creation.
|
|
29
|
+
*/
|
|
30
|
+
async function withRetry(fn) {
|
|
31
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const isTransient = e?.code?.startsWith?.('08') || // connection exception class
|
|
37
|
+
e?.message?.includes?.('Connection terminated') ||
|
|
38
|
+
e?.message?.includes?.('ECONNREFUSED');
|
|
39
|
+
if (!isTransient || attempt === MAX_RETRIES)
|
|
40
|
+
throw e;
|
|
41
|
+
const delay = Math.min(INITIAL_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
42
|
+
console.log(`[bb-distributed-data] Connection not ready, retry ${attempt + 1}/${MAX_RETRIES} in ${delay}ms`);
|
|
43
|
+
await new Promise(r => setTimeout(r, delay));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new Error('unreachable');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Provision a custom database role for the app Lambda with DML-only permissions.
|
|
50
|
+
*
|
|
51
|
+
* 1. CREATE ROLE (idempotent via pg_roles check)
|
|
52
|
+
* 2. AWS IAM GRANT — maps the DB role to the app Lambda's IAM role ARN
|
|
53
|
+
* 3. GRANT DML on user-created tables in public schema
|
|
54
|
+
*
|
|
55
|
+
* This runs on every deploy (idempotent) to ensure the role mapping stays in sync
|
|
56
|
+
* if the app Lambda's IAM role is recreated, and to re-grant DML on any new tables.
|
|
57
|
+
*
|
|
58
|
+
* DSQL Limitations:
|
|
59
|
+
* - ALTER DEFAULT PRIVILEGES is not supported
|
|
60
|
+
* - GRANT ... ON ALL TABLES IN SCHEMA public is not supported (public is a system entity)
|
|
61
|
+
* - We grant on individually enumerated user tables instead
|
|
62
|
+
*/
|
|
63
|
+
async function provisionAppRole(engine, dbRoleName, appRoleArn) {
|
|
64
|
+
console.log(`[bb-distributed-data] Provisioning DB role '${dbRoleName}' for ${appRoleArn}`);
|
|
65
|
+
// Create the role if it doesn't exist. DSQL doesn't support IF NOT EXISTS on CREATE ROLE,
|
|
66
|
+
// so we check pg_roles first.
|
|
67
|
+
const existing = await engine.query(`SELECT rolname FROM pg_roles WHERE rolname = $1`, [dbRoleName]);
|
|
68
|
+
if (existing.length === 0) {
|
|
69
|
+
await engine.execute(`CREATE ROLE ${quoteIdent(dbRoleName)} WITH LOGIN`);
|
|
70
|
+
console.log(`[bb-distributed-data] Created role '${dbRoleName}'`);
|
|
71
|
+
}
|
|
72
|
+
// Map the DB role to the app Lambda's IAM ARN (idempotent — DSQL ignores duplicate grants)
|
|
73
|
+
await engine.execute(`AWS IAM GRANT ${quoteIdent(dbRoleName)} TO '${escapeString(appRoleArn)}'`);
|
|
74
|
+
// Grant DML on user-created tables in public schema (excluding _migrations).
|
|
75
|
+
// DSQL does not support "ON ALL TABLES IN SCHEMA public" because public is a system entity.
|
|
76
|
+
// Instead, enumerate user tables and grant in a single batched statement.
|
|
77
|
+
const userTables = await engine.query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tableowner = 'admin' AND tablename != '_migrations'`);
|
|
78
|
+
if (userTables.length > 0) {
|
|
79
|
+
const tableList = userTables.map(t => `public.${quoteIdent(t.tablename)}`).join(', ');
|
|
80
|
+
await engine.execute(`GRANT SELECT, INSERT, UPDATE, DELETE ON ${tableList} TO ${quoteIdent(dbRoleName)}`);
|
|
81
|
+
console.log(`[bb-distributed-data] Granted DML on ${userTables.length} table(s) to role '${dbRoleName}'`);
|
|
82
|
+
}
|
|
83
|
+
console.log(`[bb-distributed-data] Role '${dbRoleName}' provisioned successfully`);
|
|
84
|
+
}
|
|
85
|
+
/** Quote a PostgreSQL identifier to prevent injection. */
|
|
86
|
+
function quoteIdent(name) {
|
|
87
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
88
|
+
}
|
|
89
|
+
/** Escape a string literal value (for use inside single quotes). */
|
|
90
|
+
function escapeString(value) {
|
|
91
|
+
return value.replace(/'/g, "''");
|
|
92
|
+
}
|
|
93
|
+
export const handler = async (event) => {
|
|
94
|
+
console.log('[bb-distributed-data] Migration event:', JSON.stringify({
|
|
95
|
+
RequestType: event.RequestType,
|
|
96
|
+
migrationsHash: event.ResourceProperties?.migrationsHash,
|
|
97
|
+
}));
|
|
98
|
+
if (event.RequestType === 'Delete') {
|
|
99
|
+
return { PhysicalResourceId: event.PhysicalResourceId || 'dsql-migrations' };
|
|
100
|
+
}
|
|
101
|
+
const endpoint = process.env.DSQL_ENDPOINT;
|
|
102
|
+
const region = process.env.DSQL_REGION;
|
|
103
|
+
const appRoleArn = process.env.APP_ROLE_ARN;
|
|
104
|
+
const dbRoleName = process.env.DB_ROLE_NAME;
|
|
105
|
+
if (!endpoint || !region) {
|
|
106
|
+
throw new Error(`Missing env: DSQL_ENDPOINT=${endpoint}, DSQL_REGION=${region}`);
|
|
107
|
+
}
|
|
108
|
+
if (!appRoleArn || !dbRoleName) {
|
|
109
|
+
throw new Error(`Missing env: APP_ROLE_ARN and DB_ROLE_NAME are required for role provisioning`);
|
|
110
|
+
}
|
|
111
|
+
const signer = new DsqlSigner({ hostname: endpoint, region });
|
|
112
|
+
const engine = new DsqlEngine({
|
|
113
|
+
endpoint,
|
|
114
|
+
region,
|
|
115
|
+
role: 'admin',
|
|
116
|
+
getAuthToken: () => signer.getDbConnectAdminAuthToken(),
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
// 1. Run user-defined migrations if the directory was bundled
|
|
120
|
+
if (existsSync(MIGRATIONS_DIR)) {
|
|
121
|
+
const migrations = await loadMigrationsFromDir(MIGRATIONS_DIR);
|
|
122
|
+
const applied = await withRetry(() => runMigrations(engine, migrations));
|
|
123
|
+
console.log('[bb-distributed-data] Applied:', applied.length ? applied : '(none pending)');
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log('[bb-distributed-data] No migrations directory bundled, skipping migrations');
|
|
127
|
+
}
|
|
128
|
+
// 2. Provision the app Lambda's custom DB role (least-privilege DML access)
|
|
129
|
+
await withRetry(() => provisionAppRole(engine, dbRoleName, appRoleArn));
|
|
130
|
+
return {
|
|
131
|
+
PhysicalResourceId: `dsql-migrations-${event.ResourceProperties?.migrationsHash || 'unknown'}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await engine.destroy().catch((e) => {
|
|
136
|
+
console.error('[bb-distributed-data] Failed to destroy engine during cleanup', e);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSQL-compatible migration runner.
|
|
3
|
+
*/
|
|
4
|
+
import type { DatabaseEngine } from '@aws-blocks/data-common';
|
|
5
|
+
/**
|
|
6
|
+
* Run pending migrations against a DSQL engine.
|
|
7
|
+
* DDL runs as implicit transactions, DML in explicit transactions.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runMigrations(engine: DatabaseEngine, migrations: Record<string, string>): Promise<string[]>;
|
|
10
|
+
export declare function loadMigrationsFromDir(dir: string): Promise<Record<string, string>>;
|
|
11
|
+
//# sourceMappingURL=migrations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"AAGA;;GAEG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAI9D;;;GAGG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,OAAO,CAAC,MAAM,EAAE,CAAC,CAyCnB;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAMxF"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { splitStatements } from '@aws-blocks/data-common';
|
|
4
|
+
import { validateMigrations, classifyStatement } from './validation.js';
|
|
5
|
+
/**
|
|
6
|
+
* Run pending migrations against a DSQL engine.
|
|
7
|
+
* DDL runs as implicit transactions, DML in explicit transactions.
|
|
8
|
+
*/
|
|
9
|
+
export async function runMigrations(engine, migrations) {
|
|
10
|
+
validateMigrations(migrations);
|
|
11
|
+
await engine.execute(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
13
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
14
|
+
name TEXT NOT NULL UNIQUE,
|
|
15
|
+
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
16
|
+
)
|
|
17
|
+
`);
|
|
18
|
+
const applied = await engine.query('SELECT name FROM _migrations ORDER BY name');
|
|
19
|
+
const appliedNames = new Set(applied.map(r => r.name));
|
|
20
|
+
const files = Object.keys(migrations).sort();
|
|
21
|
+
const results = [];
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
if (appliedNames.has(file))
|
|
24
|
+
continue;
|
|
25
|
+
const statements = splitStatements(migrations[file]);
|
|
26
|
+
const isDdl = statements.some(s => classifyStatement(s) === 'ddl');
|
|
27
|
+
if (isDdl) {
|
|
28
|
+
for (const stmt of statements)
|
|
29
|
+
await engine.execute(stmt);
|
|
30
|
+
await engine.execute('INSERT INTO _migrations (name) VALUES ($1)', [file]);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const handle = await engine.beginTransaction();
|
|
34
|
+
try {
|
|
35
|
+
for (const stmt of statements)
|
|
36
|
+
await engine.executeInTransaction(handle, stmt);
|
|
37
|
+
await engine.executeInTransaction(handle, 'INSERT INTO _migrations (name) VALUES ($1)', [file]);
|
|
38
|
+
await engine.commitTransaction(handle);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
await engine.rollbackTransaction(handle).catch(() => { });
|
|
42
|
+
const err = e;
|
|
43
|
+
console.error(`[bb-distributed-data] Migration failed: ${file}`, { code: err.code, severity: err.severity });
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
results.push(file);
|
|
48
|
+
console.log(`[bb-distributed-data] Applied: ${file}`);
|
|
49
|
+
}
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
export async function loadMigrationsFromDir(dir) {
|
|
53
|
+
const { readdirSync, readFileSync } = await import('node:fs');
|
|
54
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort();
|
|
55
|
+
const migrations = {};
|
|
56
|
+
for (const file of files)
|
|
57
|
+
migrations[file] = readFileSync(`${dir}/${file}`, 'utf-8');
|
|
58
|
+
return migrations;
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OCC retry logic for DistributedDatabase.transaction().
|
|
3
|
+
*/
|
|
4
|
+
import type { DatabaseBase } from '@aws-blocks/data-common';
|
|
5
|
+
import type { Transaction } from '@aws-blocks/data-common';
|
|
6
|
+
import type { TransactionOptions } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Execute a transaction with optional OCC retry.
|
|
9
|
+
* Retries on serialization failure (error 40001) up to maxRetries times.
|
|
10
|
+
*/
|
|
11
|
+
export declare function transactionWithRetry<T>(base: DatabaseBase, fn: (tx: Transaction) => Promise<T>, options?: TransactionOptions): Promise<T>;
|
|
12
|
+
//# sourceMappingURL=transaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transaction.d.ts","sourceRoot":"","sources":["../src/transaction.ts"],"names":[],"mappings":"AAGA;;GAEG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGrD;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,IAAI,EAAE,YAAY,EAClB,EAAE,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EACnC,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,CAAC,CAAC,CAaZ"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { DistributedDatabaseErrors, PG_SERIALIZATION_FAILURE } from './errors.js';
|
|
4
|
+
import { DEFAULT_MAX_RETRIES } from './constants.js';
|
|
5
|
+
/**
|
|
6
|
+
* Execute a transaction with optional OCC retry.
|
|
7
|
+
* Retries on serialization failure (error 40001) up to maxRetries times.
|
|
8
|
+
*/
|
|
9
|
+
export async function transactionWithRetry(base, fn, options) {
|
|
10
|
+
const maxAttempts = options?.retryOnConflict ? (options.maxRetries ?? DEFAULT_MAX_RETRIES) + 1 : 1;
|
|
11
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
12
|
+
try {
|
|
13
|
+
return await base.transaction(fn);
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
const isOcc = e instanceof Error &&
|
|
17
|
+
(e.code === PG_SERIALIZATION_FAILURE || e.name === DistributedDatabaseErrors.SerializationFailure);
|
|
18
|
+
if (isOcc && attempt < maxAttempts)
|
|
19
|
+
continue;
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw new Error('Transaction failed: max retries exceeded');
|
|
24
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration options for the DistributedDatabase Building Block.
|
|
3
|
+
*/
|
|
4
|
+
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
5
|
+
export interface DistributedDatabaseOptions {
|
|
6
|
+
/** Path to directory containing numbered .sql migration files. */
|
|
7
|
+
migrationsPath?: string;
|
|
8
|
+
/**
|
|
9
|
+
* CloudFormation removal policy.
|
|
10
|
+
* @default 'retain'
|
|
11
|
+
*/
|
|
12
|
+
removalPolicy?: 'destroy' | 'retain';
|
|
13
|
+
/** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
|
|
14
|
+
logger?: ChildLogger;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Options for transaction execution.
|
|
18
|
+
*/
|
|
19
|
+
export interface TransactionOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Automatically retry the transaction on OCC conflict (error 40001).
|
|
22
|
+
* ⚠️ Callback may execute multiple times. Do NOT include external side effects.
|
|
23
|
+
* @default false
|
|
24
|
+
*/
|
|
25
|
+
retryOnConflict?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Maximum retry attempts on OCC conflict. Only applies when retryOnConflict is true.
|
|
28
|
+
* @default 3
|
|
29
|
+
*/
|
|
30
|
+
maxRetries?: number;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,MAAM,WAAW,0BAA0B;IACzC,kEAAkE;IAClE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,aAAa,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IACtC,yGAAyG;IACzG,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { splitStatements } from '@aws-blocks/data-common';
|
|
2
|
+
/** Strip string literals and comments to avoid false positives. */
|
|
3
|
+
export declare function stripLiteralsAndComments(sql: string): string;
|
|
4
|
+
/** Validate a SQL statement for DSQL compatibility. Throws on unsupported features. */
|
|
5
|
+
export declare function validateStatement(sql: string): void;
|
|
6
|
+
/** Classify a statement as DDL, DML, or other. */
|
|
7
|
+
export declare function classifyStatement(sql: string): 'ddl' | 'dml' | 'other';
|
|
8
|
+
export declare class TransactionTracker {
|
|
9
|
+
private ddlCount;
|
|
10
|
+
private hasDml;
|
|
11
|
+
private rowCount;
|
|
12
|
+
recordStatement(sql: string): void;
|
|
13
|
+
recordRowCount(count: number): void;
|
|
14
|
+
reset(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function validateMigrations(migrations: Record<string, string>): void;
|
|
17
|
+
export { splitStatements };
|
|
18
|
+
//# sourceMappingURL=validation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,eAAe,EAAuB,MAAM,yBAAyB,CAAC;AAU/E,mEAAmE;AACnE,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA6C5D;AAsBD,uFAAuF;AACvF,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAenD;AAED,kDAAkD;AAClD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,GAAG,KAAK,GAAG,OAAO,CAKtE;AAID,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAK;IAErB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAalC,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAUnC,KAAK,IAAI,IAAI;CACd;AAID,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAiB3E;AAED,OAAO,EAAE,eAAe,EAAE,CAAC"}
|