@aws-blocks/bb-distributed-data 0.1.5 → 0.1.7
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/README.md +4 -0
- package/dist/engines/dsql-mock-engine.d.ts +23 -2
- package/dist/engines/dsql-mock-engine.d.ts.map +1 -1
- package/dist/engines/dsql-mock-engine.js +63 -6
- package/dist/engines/dsql-mock-engine.test.d.ts +2 -0
- package/dist/engines/dsql-mock-engine.test.d.ts.map +1 -0
- package/dist/engines/dsql-mock-engine.test.js +112 -0
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +23 -15
- package/dist/index.cdk.test.js +74 -20
- package/dist/types.d.ts +4 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -90,6 +90,10 @@ CREATE INDEX ASYNC idx_users_email ON users(email);
|
|
|
90
90
|
|
|
91
91
|
Migrations are validated at dev time — unsupported features (FK, SERIAL, TRUNCATE, etc.) are caught before deploy.
|
|
92
92
|
|
|
93
|
+
> **Set `migrationsPath` to a path relative to your project root** (e.g. `'./aws-blocks/dsql-migrations'`); it's resolved at synth from the directory you run `cdk` / `npm run deploy` in. That's the simplest reliable pattern.
|
|
94
|
+
>
|
|
95
|
+
> You don't need `fileURLToPath(import.meta.url)` for this. Your backend module runs as ESM locally but is bundled to **CommonJS** in Lambda, where `import.meta` is empty. AWS Blocks shims `import.meta.url` / `import.meta.dirname` in the bundle so it won't crash at load — but at runtime those resolve to the **bundled output** location, not your source tree. So don't use `import.meta.url` to read a file relative to your source at request time; inline the data or ship it as a Lambda asset instead.
|
|
96
|
+
|
|
93
97
|
## DSQL Limitations
|
|
94
98
|
|
|
95
99
|
DSQL is a subset of PostgreSQL. The local mock enforces these restrictions so code that works locally also works in production.
|
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* PGlite engine wrapped with DSQL validation layer for local development.
|
|
3
|
+
*/
|
|
4
|
+
import { PGlite } from '@electric-sql/pglite';
|
|
5
|
+
import { type DatabaseEngine, type TransactionHandle } from '@aws-blocks/data-common';
|
|
2
6
|
export declare class DsqlMockEngine implements DatabaseEngine {
|
|
3
7
|
private db;
|
|
4
8
|
private closed;
|
|
5
9
|
private shouldConflict;
|
|
6
10
|
private _allowDdl;
|
|
7
|
-
|
|
11
|
+
private readonly dataDir;
|
|
12
|
+
private readonly createClient;
|
|
13
|
+
private ready?;
|
|
14
|
+
/**
|
|
15
|
+
* @param dataDir - directory the PGlite mock persists to.
|
|
16
|
+
* @param createClient - factory for the underlying PGlite instance; defaults
|
|
17
|
+
* to a real `PGlite`. Exposed as a seam so tests can inject an instance
|
|
18
|
+
* that simulates a WASM init trap.
|
|
19
|
+
*/
|
|
20
|
+
constructor(dataDir: string, createClient?: (dataDir: string) => PGlite);
|
|
21
|
+
private createDb;
|
|
22
|
+
/**
|
|
23
|
+
* Force PGlite's lazy WASM initialization, retrying past intermittent
|
|
24
|
+
* `_pg_initdb` `unreachable` traps by recreating the instance. Runs once per
|
|
25
|
+
* engine; a permanent failure is not cached, so a later query can retry once
|
|
26
|
+
* transient memory pressure eases.
|
|
27
|
+
*/
|
|
28
|
+
private ensureReady;
|
|
8
29
|
/** Test helper: simulate OCC conflict on next commit. */
|
|
9
30
|
simulateConflict(): void;
|
|
10
31
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dsql-mock-engine.d.ts","sourceRoot":"","sources":["../../src/engines/dsql-mock-engine.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dsql-mock-engine.d.ts","sourceRoot":"","sources":["../../src/engines/dsql-mock-engine.ts"],"names":[],"mappings":"AAGA;;GAEG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAG9C,OAAO,EAA6B,KAAK,cAAc,EAAE,KAAK,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAoCjH,qBAAa,cAAe,YAAW,cAAc;IACnD,OAAO,CAAC,EAAE,CAAS;IACnB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA8B;IAC3D,OAAO,CAAC,KAAK,CAAC,CAAkB;IAEhC;;;;;OAKG;gBACS,OAAO,EAAE,MAAM,EAAE,YAAY,GAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAiC;IAMjG,OAAO,CAAC,QAAQ;IAMhB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IA6BnB,yDAAyD;IACzD,gBAAgB,IAAI,IAAI;IAExB;;;OAGG;IACG,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAK5C,KAAK,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IAMvD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAMvE,gBAAgB,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAQ9C,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAc3D,mBAAmB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAK7D,kBAAkB,CAAC,CAAC,EAAE,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IAO/F,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAW/G,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAK/B"}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { PGlite } from '@electric-sql/pglite';
|
|
7
7
|
import { existsSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { initializePgliteWithRetry } from '@aws-blocks/data-common';
|
|
9
10
|
import { DistributedDatabaseErrors, PG_SERIALIZATION_FAILURE, translateDsqlError } from '../errors.js';
|
|
10
11
|
import { validateStatement, classifyStatement, TransactionTracker } from '../validation.js';
|
|
11
12
|
function cleanStaleLock(dataDir) {
|
|
@@ -44,10 +45,58 @@ export class DsqlMockEngine {
|
|
|
44
45
|
closed = false;
|
|
45
46
|
shouldConflict = false;
|
|
46
47
|
_allowDdl = false;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
dataDir;
|
|
49
|
+
createClient;
|
|
50
|
+
ready;
|
|
51
|
+
/**
|
|
52
|
+
* @param dataDir - directory the PGlite mock persists to.
|
|
53
|
+
* @param createClient - factory for the underlying PGlite instance; defaults
|
|
54
|
+
* to a real `PGlite`. Exposed as a seam so tests can inject an instance
|
|
55
|
+
* that simulates a WASM init trap.
|
|
56
|
+
*/
|
|
57
|
+
constructor(dataDir, createClient = (dir) => new PGlite(dir)) {
|
|
58
|
+
this.dataDir = dataDir;
|
|
59
|
+
this.createClient = createClient;
|
|
60
|
+
this.db = this.createDb();
|
|
61
|
+
}
|
|
62
|
+
createDb() {
|
|
63
|
+
cleanStaleLock(this.dataDir);
|
|
64
|
+
mkdirSync(this.dataDir, { recursive: true });
|
|
65
|
+
return this.createClient(this.dataDir);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Force PGlite's lazy WASM initialization, retrying past intermittent
|
|
69
|
+
* `_pg_initdb` `unreachable` traps by recreating the instance. Runs once per
|
|
70
|
+
* engine; a permanent failure is not cached, so a later query can retry once
|
|
71
|
+
* transient memory pressure eases.
|
|
72
|
+
*/
|
|
73
|
+
ensureReady() {
|
|
74
|
+
if (!this.ready) {
|
|
75
|
+
this.ready = initializePgliteWithRetry(this.db, () => (this.db = this.createDb()), {
|
|
76
|
+
onRetry: (attempt, error) => console.warn(`[DsqlMockEngine] PGlite init trap on attempt ${attempt}; recreating instance`, error),
|
|
77
|
+
})
|
|
78
|
+
// Pin this.db to the settled instance explicitly (not just via the recreate closure).
|
|
79
|
+
.then((db) => (this.db = db))
|
|
80
|
+
.catch((error) => {
|
|
81
|
+
// Init retry is exhausted; initializePgliteWithRetry has already closed
|
|
82
|
+
// the last trapped instance, so this.db now points at a dead handle.
|
|
83
|
+
// Reset readiness AND swap in a fresh, un-probed instance. Without the
|
|
84
|
+
// swap, the next call would re-probe the CLOSED handle, whose error is a
|
|
85
|
+
// "closed" error (not an `unreachable` trap) and is therefore classified
|
|
86
|
+
// non-retryable — permanently wedging the engine and defeating the
|
|
87
|
+
// "a later query can retry" recovery documented above. Recreating here
|
|
88
|
+
// is best effort; if it throws we keep propagating the original init error.
|
|
89
|
+
this.ready = undefined;
|
|
90
|
+
try {
|
|
91
|
+
this.db = this.createDb();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Leave the dead handle in place; the original init error is more useful.
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return this.ready;
|
|
51
100
|
}
|
|
52
101
|
/** Test helper: simulate OCC conflict on next commit. */
|
|
53
102
|
simulateConflict() { this.shouldConflict = true; }
|
|
@@ -67,6 +116,7 @@ export class DsqlMockEngine {
|
|
|
67
116
|
async query(sql, params) {
|
|
68
117
|
const normalized = preprocessSqlForDsqlMock(sql, { allowDdl: this._allowDdl });
|
|
69
118
|
try {
|
|
119
|
+
await this.ensureReady();
|
|
70
120
|
return (await this.db.query(normalized, params)).rows;
|
|
71
121
|
}
|
|
72
122
|
catch (e) {
|
|
@@ -76,6 +126,7 @@ export class DsqlMockEngine {
|
|
|
76
126
|
async execute(sql, params) {
|
|
77
127
|
const normalized = preprocessSqlForDsqlMock(sql, { allowDdl: this._allowDdl });
|
|
78
128
|
try {
|
|
129
|
+
await this.ensureReady();
|
|
79
130
|
return { rowCount: (await this.db.query(normalized, params)).affectedRows ?? 0 };
|
|
80
131
|
}
|
|
81
132
|
catch (e) {
|
|
@@ -83,8 +134,14 @@ export class DsqlMockEngine {
|
|
|
83
134
|
}
|
|
84
135
|
}
|
|
85
136
|
async beginTransaction() {
|
|
86
|
-
|
|
87
|
-
|
|
137
|
+
try {
|
|
138
|
+
await this.ensureReady();
|
|
139
|
+
await this.db.query('BEGIN');
|
|
140
|
+
return { active: true, tracker: new TransactionTracker() };
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
translateDsqlError(e);
|
|
144
|
+
}
|
|
88
145
|
}
|
|
89
146
|
async commitTransaction(handle) {
|
|
90
147
|
if (this.shouldConflict) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dsql-mock-engine.test.d.ts","sourceRoot":"","sources":["../../src/engines/dsql-mock-engine.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { test, afterEach } from 'node:test';
|
|
4
|
+
import assert from 'node:assert';
|
|
5
|
+
import { PGlite } from '@electric-sql/pglite';
|
|
6
|
+
import { rmSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { DsqlMockEngine } from './dsql-mock-engine.js';
|
|
9
|
+
import { DistributedDatabaseErrors } from '../errors.js';
|
|
10
|
+
const TEST_DIR = '.bb-data-dsql-mock-test-' + process.pid;
|
|
11
|
+
let engine;
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (engine)
|
|
14
|
+
await engine.destroy().catch(() => { });
|
|
15
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
// --- Regression #188: PGlite WASM `_pg_initdb` `unreachable` init trap ---
|
|
18
|
+
// The DSQL mock wraps PGlite; the same lazy-init WASM trap kills the dev server
|
|
19
|
+
// during runMigrations. A factory injects a first instance that traps on the
|
|
20
|
+
// init probe; recovery falls through to a REAL PGlite so data round-trips.
|
|
21
|
+
test('recovers when the first PGlite instance traps during init', async () => {
|
|
22
|
+
const dir = join(TEST_DIR, 'init-trap-recover');
|
|
23
|
+
let creates = 0;
|
|
24
|
+
const factory = (d) => {
|
|
25
|
+
creates++;
|
|
26
|
+
if (creates === 1) {
|
|
27
|
+
return {
|
|
28
|
+
query: async () => {
|
|
29
|
+
throw new Error('Aborted(). Build with -sASSERTIONS for more info. RuntimeError: unreachable');
|
|
30
|
+
},
|
|
31
|
+
close: async () => { },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return new PGlite(d);
|
|
35
|
+
};
|
|
36
|
+
engine = new DsqlMockEngine(dir, factory);
|
|
37
|
+
await engine.withDdl(() => engine.execute('CREATE TABLE t (id TEXT PRIMARY KEY)'));
|
|
38
|
+
await engine.execute("INSERT INTO t (id) VALUES ('ok')");
|
|
39
|
+
const rows = await engine.query('SELECT id FROM t');
|
|
40
|
+
assert.deepStrictEqual(rows, [{ id: 'ok' }]);
|
|
41
|
+
assert.strictEqual(creates, 2, 'engine should recreate the trapped instance exactly once');
|
|
42
|
+
});
|
|
43
|
+
test('surfaces a QueryFailed error when init keeps trapping', async () => {
|
|
44
|
+
const dir = join(TEST_DIR, 'init-trap-persistent');
|
|
45
|
+
const factory = () => ({
|
|
46
|
+
query: async () => {
|
|
47
|
+
throw new Error('RuntimeError: unreachable');
|
|
48
|
+
},
|
|
49
|
+
close: async () => { },
|
|
50
|
+
});
|
|
51
|
+
engine = new DsqlMockEngine(dir, factory);
|
|
52
|
+
await assert.rejects(() => engine.execute("INSERT INTO t (id) VALUES ('x')"), (err) => {
|
|
53
|
+
assert.strictEqual(err.name, DistributedDatabaseErrors.QueryFailed);
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
// beginTransaction() forces the same lazy WASM init as query()/execute(); an
|
|
58
|
+
// exhausted init-retry at transaction start must also normalize to QueryFailed
|
|
59
|
+
// rather than leaking a raw `RuntimeError: unreachable`.
|
|
60
|
+
test('surfaces a QueryFailed error when init keeps trapping during beginTransaction', async () => {
|
|
61
|
+
const dir = join(TEST_DIR, 'init-trap-begin-transaction');
|
|
62
|
+
const factory = () => ({
|
|
63
|
+
query: async () => {
|
|
64
|
+
throw new Error('RuntimeError: unreachable');
|
|
65
|
+
},
|
|
66
|
+
close: async () => { },
|
|
67
|
+
});
|
|
68
|
+
engine = new DsqlMockEngine(dir, factory);
|
|
69
|
+
await assert.rejects(() => engine.beginTransaction(), (err) => {
|
|
70
|
+
assert.strictEqual(err.name, DistributedDatabaseErrors.QueryFailed);
|
|
71
|
+
return true;
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
// --- Regression #188 (recovery): a later call must succeed after the init
|
|
75
|
+
// retry budget is fully exhausted, once the factory stops trapping. The
|
|
76
|
+
// exhausted attempt closes the last instance; keeping that dead handle would
|
|
77
|
+
// make the next call re-probe a CLOSED instance whose error is a "closed"
|
|
78
|
+
// error (NOT `unreachable`) and thus non-retryable, wedging the engine
|
|
79
|
+
// permanently. Trapped instances model a real PGlite: `unreachable` while
|
|
80
|
+
// open, a "closed" error after close().
|
|
81
|
+
test('recovers on a later call after the init-retry budget is exhausted', async () => {
|
|
82
|
+
const dir = join(TEST_DIR, 'init-trap-exhaust-recover');
|
|
83
|
+
let creates = 0;
|
|
84
|
+
const factory = (d) => {
|
|
85
|
+
creates++;
|
|
86
|
+
if (creates <= 3) {
|
|
87
|
+
let closed = false;
|
|
88
|
+
return {
|
|
89
|
+
query: async () => {
|
|
90
|
+
throw new Error(closed ? 'PGlite is closed' : 'Aborted(). RuntimeError: unreachable');
|
|
91
|
+
},
|
|
92
|
+
close: async () => {
|
|
93
|
+
closed = true;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return new PGlite(d);
|
|
98
|
+
};
|
|
99
|
+
engine = new DsqlMockEngine(dir, factory);
|
|
100
|
+
// First call exhausts the 3-attempt retry budget and rejects.
|
|
101
|
+
await assert.rejects(() => engine.withDdl(() => engine.execute('CREATE TABLE t (id TEXT PRIMARY KEY)')), (err) => {
|
|
102
|
+
assert.strictEqual(err.name, DistributedDatabaseErrors.QueryFailed);
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
// Once the factory stops trapping, a later call must recover on a fresh
|
|
106
|
+
// instance instead of re-probing the closed handle forever.
|
|
107
|
+
await engine.withDdl(() => engine.execute('CREATE TABLE t (id TEXT PRIMARY KEY)'));
|
|
108
|
+
await engine.execute("INSERT INTO t (id) VALUES ('ok')");
|
|
109
|
+
const rows = await engine.query('SELECT id FROM t');
|
|
110
|
+
assert.deepStrictEqual(rows, [{ id: 'ok' }]);
|
|
111
|
+
assert.ok(creates >= 4, 'engine should build a fresh instance after exhaustion');
|
|
112
|
+
});
|
package/dist/index.cdk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,EAA0E,MAAM,sBAAsB,CAAC;AACrH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAQpD,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAG7D,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,0BAA0B;IAkGhF;;;;;OAKG;IACH,SAAS,IAAI,KAAK;CAGnB;AAaD,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,MAAM,YAAY,CAAC"}
|
package/dist/index.cdk.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Provisions Aurora DSQL cluster via CloudFormation.
|
|
6
6
|
* Optionally runs migrations via a CustomResource Lambda.
|
|
7
7
|
*/
|
|
8
|
-
import { Scope, DEFAULT_NODE_RUNTIME, synthGuard } from '@aws-blocks/core/cdk';
|
|
8
|
+
import { Scope, DEFAULT_NODE_RUNTIME, synthGuard, blocksNodejsBundling, registerConfig } from '@aws-blocks/core/cdk';
|
|
9
9
|
import * as cdk from 'aws-cdk-lib';
|
|
10
10
|
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
11
11
|
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs';
|
|
@@ -18,32 +18,40 @@ export class DistributedDatabase extends Scope {
|
|
|
18
18
|
constructor(scope, id, options) {
|
|
19
19
|
super(id, { parent: scope });
|
|
20
20
|
const stack = cdk.Stack.of(this);
|
|
21
|
-
const isSandbox = stack.node.tryGetContext('sandboxMode') === 'true';
|
|
22
21
|
const envName = this.fullId.replace(ENV_SANITIZE, '_');
|
|
23
22
|
const region = stack.region;
|
|
24
23
|
const dbRole = sanitizeDbRoleName(this.fullId);
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
// Removal policy and deletion protection are resolved independently from the
|
|
25
|
+
// stack-wide `defaults` (per-block `removalPolicy` option wins for that field).
|
|
26
|
+
// Reading `defaults.deletionProtection` directly — rather than deriving it
|
|
27
|
+
// from `removalPolicy` — keeps every adopting block consistent: the same
|
|
28
|
+
// `defaults` object yields the same posture no matter which block reads it.
|
|
29
|
+
const removalPolicy = options?.removalPolicy === 'destroy'
|
|
30
|
+
? cdk.RemovalPolicy.DESTROY
|
|
31
|
+
: options?.removalPolicy === 'retain'
|
|
32
|
+
? cdk.RemovalPolicy.RETAIN
|
|
33
|
+
: this.defaults.removalPolicy;
|
|
28
34
|
const cluster = new cdk.CfnResource(stack, `${this.fullId}DsqlCluster`, {
|
|
29
35
|
type: 'AWS::DSQL::Cluster',
|
|
30
36
|
properties: {
|
|
31
|
-
DeletionProtectionEnabled:
|
|
37
|
+
DeletionProtectionEnabled: this.defaults.deletionProtection,
|
|
32
38
|
},
|
|
33
39
|
});
|
|
34
40
|
cluster.applyRemovalPolicy(removalPolicy);
|
|
35
41
|
const endpoint = cluster.getAtt('Endpoint').toString();
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
this
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
// Config for runtime — flows through S3 config (loaded into process.env at
|
|
43
|
+
// cold start) like every other block, instead of a direct env var.
|
|
44
|
+
registerConfig(this, `BLOCKS_${envName}_ENDPOINT`, endpoint);
|
|
45
|
+
registerConfig(this, `BLOCKS_${envName}_REGION`, region);
|
|
46
|
+
// IAM grant — the shared execution role gets DML-only access via custom DB
|
|
47
|
+
// role (least privilege).
|
|
48
|
+
this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
41
49
|
actions: ['dsql:DbConnect'],
|
|
42
50
|
resources: [`arn:aws:dsql:${region}:${stack.account}:cluster/${cluster.ref}`],
|
|
43
51
|
}));
|
|
44
52
|
new cdk.CfnOutput(stack, `${this.fullId}DsqlEndpoint`, { value: endpoint });
|
|
45
|
-
// The
|
|
46
|
-
const appRoleArn = this.
|
|
53
|
+
// The shared execution role ARN is mapped to the custom DB role.
|
|
54
|
+
const appRoleArn = this.executionRole.roleArn;
|
|
47
55
|
// Resolve migrations path if provided
|
|
48
56
|
const resolvedMigrationsPath = options?.migrationsPath ? resolve(options.migrationsPath) : undefined;
|
|
49
57
|
const migrationsHash = resolvedMigrationsPath ? hashMigrationsDir(resolvedMigrationsPath) : 'no-migrations';
|
|
@@ -63,7 +71,7 @@ export class DistributedDatabase extends Scope {
|
|
|
63
71
|
APP_ROLE_ARN: appRoleArn,
|
|
64
72
|
DB_ROLE_NAME: dbRole,
|
|
65
73
|
},
|
|
66
|
-
bundling: {
|
|
74
|
+
bundling: blocksNodejsBundling({
|
|
67
75
|
commandHooks: {
|
|
68
76
|
beforeBundling: () => [],
|
|
69
77
|
beforeInstall: () => [],
|
|
@@ -71,7 +79,7 @@ export class DistributedDatabase extends Scope {
|
|
|
71
79
|
? [`cp -r ${resolvedMigrationsPath} ${outputDir}${LAMBDA_MIGRATIONS_DIR.replace('/var/task', '')}`]
|
|
72
80
|
: [],
|
|
73
81
|
},
|
|
74
|
-
},
|
|
82
|
+
}),
|
|
75
83
|
});
|
|
76
84
|
// Migration Lambda needs Admin access (DDL + role management)
|
|
77
85
|
migrationFn.addToRolePolicy(new iam.PolicyStatement({
|
package/dist/index.cdk.test.js
CHANGED
|
@@ -12,18 +12,23 @@ import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
|
12
12
|
import { Template, Match } from 'aws-cdk-lib/assertions';
|
|
13
13
|
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
-
import { DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
15
|
+
import { DEFAULT_NODE_RUNTIME, BlocksPresets } from '@aws-blocks/core/cdk';
|
|
16
16
|
import { DistributedDatabase } from './index.cdk.js';
|
|
17
17
|
const MIGRATIONS_DIR = '.bb-data/__test_cdk_migrations__';
|
|
18
18
|
function synth(build) {
|
|
19
19
|
const app = new cdk.App();
|
|
20
20
|
const stack = new cdk.Stack(app, 'TestStack');
|
|
21
|
+
const executionRole = new cdk.aws_iam.Role(stack, 'BlocksRole', {
|
|
22
|
+
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
23
|
+
});
|
|
21
24
|
const handler = new lambda.Function(stack, 'Handler', {
|
|
22
25
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
23
26
|
handler: 'index.handler',
|
|
24
27
|
code: lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
28
|
+
role: executionRole,
|
|
25
29
|
});
|
|
26
30
|
stack.handler = handler;
|
|
31
|
+
stack.executionRole = executionRole;
|
|
27
32
|
globalThis.CURRENT_BLOCKS_STACK = stack;
|
|
28
33
|
try {
|
|
29
34
|
build(stack);
|
|
@@ -52,24 +57,55 @@ test('CDK: cluster has DeletionProtectionEnabled=true by default', () => {
|
|
|
52
57
|
DeletionPolicy: 'Retain',
|
|
53
58
|
});
|
|
54
59
|
});
|
|
55
|
-
test('CDK: removalPolicy
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
test('CDK: per-block removalPolicy is independent of deletion protection', () => {
|
|
61
|
+
// Deletion protection is read from `defaults` independently of removalPolicy
|
|
62
|
+
// (consistent across all adopting blocks). Here: sandbox defaults (protection
|
|
63
|
+
// off) with a per-block `removalPolicy: 'retain'` override → the cluster is
|
|
64
|
+
// RETAINed on stack delete but not deletion-protected.
|
|
65
|
+
const app = new cdk.App();
|
|
66
|
+
const stack = new cdk.Stack(app, 'IndepStack');
|
|
67
|
+
const executionRole = new cdk.aws_iam.Role(stack, 'BlocksRole', {
|
|
68
|
+
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
58
69
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
const handler = new lambda.Function(stack, 'Handler', {
|
|
71
|
+
runtime: DEFAULT_NODE_RUNTIME,
|
|
72
|
+
handler: 'index.handler',
|
|
73
|
+
code: lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
74
|
+
role: executionRole,
|
|
62
75
|
});
|
|
76
|
+
stack.handler = handler;
|
|
77
|
+
stack.defaults = BlocksPresets.sandbox;
|
|
78
|
+
stack.executionRole = executionRole;
|
|
79
|
+
globalThis.CURRENT_BLOCKS_STACK = stack;
|
|
80
|
+
try {
|
|
81
|
+
new DistributedDatabase(scope(stack), 'mydsql', { removalPolicy: 'retain' });
|
|
82
|
+
const template = Template.fromStack(stack);
|
|
83
|
+
template.hasResource('AWS::DSQL::Cluster', {
|
|
84
|
+
Properties: { DeletionProtectionEnabled: false },
|
|
85
|
+
DeletionPolicy: 'Retain',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
delete globalThis.CURRENT_BLOCKS_STACK;
|
|
90
|
+
}
|
|
63
91
|
});
|
|
64
|
-
test('CDK:
|
|
65
|
-
const app = new cdk.App(
|
|
92
|
+
test('CDK: sandbox defaults disable deletion protection', () => {
|
|
93
|
+
const app = new cdk.App();
|
|
66
94
|
const stack = new cdk.Stack(app, 'SandboxStack');
|
|
95
|
+
const executionRole = new cdk.aws_iam.Role(stack, 'BlocksRole', {
|
|
96
|
+
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
97
|
+
});
|
|
67
98
|
const handler = new lambda.Function(stack, 'Handler', {
|
|
68
99
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
69
100
|
handler: 'index.handler',
|
|
70
101
|
code: lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
102
|
+
role: executionRole,
|
|
71
103
|
});
|
|
72
104
|
stack.handler = handler;
|
|
105
|
+
// The cluster's removal/protection follows the stack-wide defaults (resolved
|
|
106
|
+
// via the globalThis fallback here), not the sandboxMode context.
|
|
107
|
+
stack.defaults = BlocksPresets.sandbox;
|
|
108
|
+
stack.executionRole = executionRole;
|
|
73
109
|
globalThis.CURRENT_BLOCKS_STACK = stack;
|
|
74
110
|
try {
|
|
75
111
|
new DistributedDatabase(scope(stack), 'mydsql');
|
|
@@ -99,18 +135,36 @@ test('CDK: handler gets dsql:DbConnect policy (least privilege)', () => {
|
|
|
99
135
|
},
|
|
100
136
|
});
|
|
101
137
|
});
|
|
102
|
-
// ---
|
|
103
|
-
test('CDK:
|
|
104
|
-
const
|
|
105
|
-
|
|
138
|
+
// --- Runtime config (endpoint + region) ---
|
|
139
|
+
test('CDK: registers ENDPOINT and REGION via the config registry (not handler env vars)', () => {
|
|
140
|
+
const app = new cdk.App();
|
|
141
|
+
const stack = new cdk.Stack(app, 'TestStack');
|
|
142
|
+
const executionRole = new cdk.aws_iam.Role(stack, 'BlocksRole', {
|
|
143
|
+
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
106
144
|
});
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
145
|
+
const handler = new lambda.Function(stack, 'Handler', {
|
|
146
|
+
runtime: DEFAULT_NODE_RUNTIME,
|
|
147
|
+
handler: 'index.handler',
|
|
148
|
+
code: lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
149
|
+
role: executionRole,
|
|
150
|
+
});
|
|
151
|
+
stack.handler = handler;
|
|
152
|
+
stack.executionRole = executionRole;
|
|
153
|
+
globalThis.CURRENT_BLOCKS_STACK = stack;
|
|
154
|
+
try {
|
|
155
|
+
new DistributedDatabase(scope(stack), 'mydsql');
|
|
156
|
+
// Endpoint + region now flow through the config registry (loaded into
|
|
157
|
+
// process.env at cold start) like every other block — not a direct env var
|
|
158
|
+
// on the handler.
|
|
159
|
+
const registry = stack[Symbol.for('BLOCKS_CONFIG_REGISTRY')];
|
|
160
|
+
assert.ok(registry, 'config registry exists on the stack');
|
|
161
|
+
const keys = [...registry.entries.keys()];
|
|
162
|
+
assert.ok(keys.some(k => k.includes('ENDPOINT')), `Expected an ENDPOINT config key, got: ${keys}`);
|
|
163
|
+
assert.ok(keys.some(k => k.includes('REGION')), `Expected a REGION config key, got: ${keys}`);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
delete globalThis.CURRENT_BLOCKS_STACK;
|
|
167
|
+
}
|
|
114
168
|
});
|
|
115
169
|
// --- CfnOutput ---
|
|
116
170
|
test('CDK: stack has endpoint output', () => {
|
package/dist/types.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ export interface DistributedDatabaseOptions {
|
|
|
6
6
|
/** Path to directory containing numbered .sql migration files. */
|
|
7
7
|
migrationsPath?: string;
|
|
8
8
|
/**
|
|
9
|
-
* CloudFormation removal policy.
|
|
10
|
-
*
|
|
9
|
+
* CloudFormation removal policy for the DSQL cluster. When omitted, the
|
|
10
|
+
* stack-wide `defaults` apply (`production` → RETAIN, `sandbox` → DESTROY).
|
|
11
|
+
* Pass `'destroy'`/`'retain'` to override for this one database. Deletion
|
|
12
|
+
* protection follows: on unless the cluster is being destroyed.
|
|
11
13
|
*/
|
|
12
14
|
removalPolicy?: 'destroy' | 'retain';
|
|
13
15
|
/** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +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
|
|
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;;;;;OAKG;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/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aws-blocks/bb-distributed-data",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"test": "node --test 'dist/**/*.test.js'"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@aws-blocks/core": "^0.
|
|
41
|
-
"@aws-blocks/data-common": "^0.1.
|
|
42
|
-
"@aws-blocks/bb-logger": "^0.1.
|
|
40
|
+
"@aws-blocks/core": "^0.3.0",
|
|
41
|
+
"@aws-blocks/data-common": "^0.1.4",
|
|
42
|
+
"@aws-blocks/bb-logger": "^0.1.5",
|
|
43
43
|
"@aws-sdk/dsql-signer": "^3.700.0",
|
|
44
44
|
"@electric-sql/pglite": "^0.2.0",
|
|
45
45
|
"pg": "^8.13.0"
|