@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.
Files changed (57) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +243 -0
  3. package/dist/constants.d.ts +38 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/constants.js +50 -0
  6. package/dist/e2e-mock.test.d.ts +2 -0
  7. package/dist/e2e-mock.test.d.ts.map +1 -0
  8. package/dist/e2e-mock.test.js +185 -0
  9. package/dist/e2e.test.d.ts +2 -0
  10. package/dist/e2e.test.d.ts.map +1 -0
  11. package/dist/e2e.test.js +329 -0
  12. package/dist/engines/dsql-engine.d.ts +26 -0
  13. package/dist/engines/dsql-engine.d.ts.map +1 -0
  14. package/dist/engines/dsql-engine.js +103 -0
  15. package/dist/engines/dsql-mock-engine.d.ts +28 -0
  16. package/dist/engines/dsql-mock-engine.d.ts.map +1 -0
  17. package/dist/engines/dsql-mock-engine.js +132 -0
  18. package/dist/errors.d.ts +29 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +45 -0
  21. package/dist/errors.test.d.ts +2 -0
  22. package/dist/errors.test.d.ts.map +1 -0
  23. package/dist/errors.test.js +55 -0
  24. package/dist/index.aws.d.ts +35 -0
  25. package/dist/index.aws.d.ts.map +1 -0
  26. package/dist/index.aws.js +59 -0
  27. package/dist/index.browser.d.ts +5 -0
  28. package/dist/index.browser.d.ts.map +1 -0
  29. package/dist/index.browser.js +7 -0
  30. package/dist/index.cdk.d.ts +16 -0
  31. package/dist/index.cdk.d.ts.map +1 -0
  32. package/dist/index.cdk.js +103 -0
  33. package/dist/index.cdk.test.d.ts +2 -0
  34. package/dist/index.cdk.test.d.ts.map +1 -0
  35. package/dist/index.cdk.test.js +182 -0
  36. package/dist/index.mock.d.ts +39 -0
  37. package/dist/index.mock.d.ts.map +1 -0
  38. package/dist/index.mock.js +53 -0
  39. package/dist/migration-lambda.d.ts +5 -0
  40. package/dist/migration-lambda.d.ts.map +1 -0
  41. package/dist/migration-lambda.js +139 -0
  42. package/dist/migrations.d.ts +11 -0
  43. package/dist/migrations.d.ts.map +1 -0
  44. package/dist/migrations.js +59 -0
  45. package/dist/transaction.d.ts +12 -0
  46. package/dist/transaction.d.ts.map +1 -0
  47. package/dist/transaction.js +24 -0
  48. package/dist/types.d.ts +32 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +3 -0
  51. package/dist/validation.d.ts +18 -0
  52. package/dist/validation.d.ts.map +1 -0
  53. package/dist/validation.js +169 -0
  54. package/dist/validation.test.d.ts +2 -0
  55. package/dist/validation.test.d.ts.map +1 -0
  56. package/dist/validation.test.js +354 -0
  57. package/package.json +48 -0
@@ -0,0 +1,185 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * E2E tests for DistributedDatabase mock engine (PGlite + validation).
5
+ */
6
+ import { describe, it, before, after } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { rmSync } from 'node:fs';
9
+ import { DatabaseBase, sql } from '@aws-blocks/data-common';
10
+ import { DsqlMockEngine } from './engines/dsql-mock-engine.js';
11
+ import { DistributedDatabaseErrors } from './errors.js';
12
+ import { runMigrations } from './migrations.js';
13
+ import { transactionWithRetry } from './transaction.js';
14
+ const DIR = '.bb-data/__test_dsql_e2e__';
15
+ describe('DistributedDatabase E2E (mock)', () => {
16
+ let engine;
17
+ let db;
18
+ before(async () => {
19
+ rmSync(DIR, { recursive: true, force: true });
20
+ engine = new DsqlMockEngine(DIR);
21
+ db = new DatabaseBase(engine);
22
+ // Schema setup is DDL, which the app runtime rejects (parity with prod
23
+ // dsql:DbConnect). Run it through the migration-style withDdl escape hatch.
24
+ await engine.withDdl(async () => {
25
+ await db.execute(sql `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE)`);
26
+ await db.execute(sql `CREATE TABLE accounts (id TEXT PRIMARY KEY, balance INT NOT NULL DEFAULT 0)`);
27
+ });
28
+ });
29
+ after(async () => {
30
+ await engine.destroy();
31
+ rmSync(DIR, { recursive: true, force: true });
32
+ });
33
+ it('CRUD', async () => {
34
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${'u1'}, ${'Alice'}, ${'a@t.com'})`);
35
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${'u2'}, ${'Bob'}, ${'b@t.com'})`);
36
+ const rows = await db.query(sql `SELECT name FROM users ORDER BY name`);
37
+ assert.equal(rows.length, 2);
38
+ assert.equal(rows[0].name, 'Alice');
39
+ const one = await db.queryOne(sql `SELECT name FROM users WHERE id = ${'u1'}`);
40
+ assert.equal(one?.name, 'Alice');
41
+ assert.equal(await db.queryOne(sql `SELECT * FROM users WHERE id = ${'x'}`), null);
42
+ const upd = await db.execute(sql `UPDATE users SET name = ${'A2'} WHERE id = ${'u1'}`);
43
+ assert.equal(upd.rowCount, 1);
44
+ });
45
+ it('transaction commits', async () => {
46
+ await db.execute(sql `INSERT INTO accounts (id, balance) VALUES (${'a1'}, ${1000})`);
47
+ await db.execute(sql `INSERT INTO accounts (id, balance) VALUES (${'a2'}, ${500})`);
48
+ await db.transaction(async (tx) => {
49
+ await tx.execute(sql `UPDATE accounts SET balance = balance - ${100} WHERE id = ${'a1'}`);
50
+ await tx.execute(sql `UPDATE accounts SET balance = balance + ${100} WHERE id = ${'a2'}`);
51
+ });
52
+ const a1 = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${'a1'}`);
53
+ assert.equal(a1?.balance, 900);
54
+ });
55
+ it('transaction rolls back on error', async () => {
56
+ const before = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${'a1'}`);
57
+ await assert.rejects(() => db.transaction(async (tx) => {
58
+ await tx.execute(sql `UPDATE accounts SET balance = 0 WHERE id = ${'a1'}`);
59
+ throw new Error('fail');
60
+ }));
61
+ const after = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${'a1'}`);
62
+ assert.equal(after?.balance, before?.balance);
63
+ });
64
+ it('simulateConflict throws SerializationFailure', async () => {
65
+ engine.simulateConflict();
66
+ await assert.rejects(() => db.transaction(async (tx) => { await tx.execute(sql `UPDATE accounts SET balance = 1 WHERE id = ${'a1'}`); }), (e) => { assert.equal(e.name, DistributedDatabaseErrors.SerializationFailure); return true; });
67
+ });
68
+ it('retryOnConflict recovers', async () => {
69
+ engine.simulateConflict();
70
+ const result = await transactionWithRetry(db, async (tx) => { await tx.execute(sql `UPDATE accounts SET balance = balance + ${1} WHERE id = ${'a2'}`); return 'ok'; }, { retryOnConflict: true });
71
+ assert.equal(result, 'ok');
72
+ });
73
+ it('rejects FK at query time', async () => {
74
+ await assert.rejects(() => db.execute(sql `CREATE TABLE bad (id TEXT REFERENCES users(id))`), { name: 'DsqlValidationError' });
75
+ });
76
+ it('rejects DDL+DML in transaction', async () => {
77
+ await assert.rejects(() => db.transaction(async (tx) => {
78
+ await tx.execute(sql `INSERT INTO users (id, name) VALUES (${'x'}, ${'X'})`);
79
+ await tx.execute(sql `CREATE TABLE fail (id TEXT)`);
80
+ }), { name: 'DsqlPermissionError' });
81
+ });
82
+ it('unique constraint violation', async () => {
83
+ await assert.rejects(() => db.execute(sql `INSERT INTO users (id, name, email) VALUES (${'u1'}, ${'Dup'}, ${'dup@t.com'})`), (e) => { assert.equal(e.name, DistributedDatabaseErrors.UniqueConstraintViolation); return true; });
84
+ });
85
+ });
86
+ describe('Migration runner E2E', () => {
87
+ const DIR2 = '.bb-data/__test_dsql_migrations__';
88
+ let engine;
89
+ before(async () => { rmSync(DIR2, { recursive: true, force: true }); engine = new DsqlMockEngine(DIR2); });
90
+ after(async () => { await engine.destroy(); rmSync(DIR2, { recursive: true, force: true }); });
91
+ it('runs and tracks migrations', async () => {
92
+ const applied = await engine.withDdl(() => runMigrations(engine, {
93
+ '001.sql': 'CREATE TABLE t (id TEXT PRIMARY KEY, name TEXT)',
94
+ '002.sql': "INSERT INTO t (id, name) VALUES ('1', 'Admin')",
95
+ }));
96
+ assert.deepEqual(applied, ['001.sql', '002.sql']);
97
+ const rows = await engine.query('SELECT name FROM t');
98
+ assert.equal(rows[0].name, 'Admin');
99
+ });
100
+ it('skips already-applied', async () => {
101
+ const applied = await engine.withDdl(() => runMigrations(engine, {
102
+ '001.sql': 'CREATE TABLE t (id TEXT PRIMARY KEY, name TEXT)',
103
+ '002.sql': "INSERT INTO t (id, name) VALUES ('1', 'Admin')",
104
+ '003.sql': 'ALTER TABLE t ADD COLUMN age INT',
105
+ }));
106
+ assert.deepEqual(applied, ['003.sql']);
107
+ });
108
+ it('rejects invalid migrations', async () => {
109
+ await assert.rejects(() => engine.withDdl(() => runMigrations(engine, {
110
+ '004.sql': 'CREATE TABLE bad (id TEXT REFERENCES t(id))',
111
+ })), { name: 'DsqlMigrationValidationError' });
112
+ });
113
+ });
114
+ // ─── DsqlMockEngine — CREATE INDEX ASYNC parity ─────────────────────────────
115
+ // DSQL supports `CREATE INDEX ASYNC` for non-blocking index builds. The mock
116
+ // engine must accept this syntax and execute it synchronously (PGlite doesn't
117
+ // understand the ASYNC keyword natively).
118
+ describe('DsqlMockEngine — CREATE INDEX ASYNC parity', () => {
119
+ const DIR3 = '.bb-data/__test_dsql_async_index__';
120
+ let engine;
121
+ let db;
122
+ before(async () => {
123
+ rmSync(DIR3, { recursive: true, force: true });
124
+ engine = new DsqlMockEngine(DIR3);
125
+ db = new DatabaseBase(engine);
126
+ // CREATE INDEX is DDL, which the app runtime rejects. This suite exercises
127
+ // the engine's CREATE INDEX ASYNC preprocessing directly, so run all DDL
128
+ // through the withDdl escape hatch (the same one the migration runner uses).
129
+ await engine.withDdl(() => db.execute(sql `CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, active BOOLEAN DEFAULT true)`));
130
+ });
131
+ after(async () => {
132
+ await engine.destroy();
133
+ rmSync(DIR3, { recursive: true, force: true });
134
+ });
135
+ it('accepts CREATE INDEX ASYNC (executes as synchronous locally)', async () => {
136
+ await assert.doesNotReject(() => engine.withDdl(() => db.execute(sql `CREATE INDEX ASYNC idx_users_email ON users(email)`)));
137
+ });
138
+ it('makes the index usable immediately after CREATE INDEX ASYNC', async () => {
139
+ await engine.withDdl(() => db.execute(sql `CREATE INDEX ASYNC idx_users_active ON users(active)`));
140
+ // If the index was actually created, EXPLAIN/usage and inserts still work.
141
+ await db.execute(sql `INSERT INTO users (id, email, active) VALUES (${'u-async'}, ${'a@t.com'}, ${true})`);
142
+ const row = await db.queryOne(sql `SELECT id FROM users WHERE active = ${true} AND id = ${'u-async'}`);
143
+ assert.equal(row?.id, 'u-async');
144
+ });
145
+ it('accepts CREATE UNIQUE INDEX ASYNC', async () => {
146
+ await assert.doesNotReject(() => engine.withDdl(() => db.execute(sql `CREATE UNIQUE INDEX ASYNC idx_users_email_uniq ON users(email)`)));
147
+ });
148
+ it('accepts CREATE INDEX ASYNC with IF NOT EXISTS', async () => {
149
+ await assert.doesNotReject(() => engine.withDdl(() => db.execute(sql `CREATE INDEX ASYNC IF NOT EXISTS idx_users_email2 ON users(email)`)));
150
+ });
151
+ it('accepts a partial CREATE INDEX ASYNC (WHERE clause)', async () => {
152
+ await assert.doesNotReject(() => engine.withDdl(() => db.execute(sql `CREATE INDEX ASYNC idx_users_active_email ON users(email) WHERE active = true`)));
153
+ });
154
+ it('still supports a plain CREATE INDEX (no ASYNC)', async () => {
155
+ await assert.doesNotReject(() => engine.withDdl(() => db.execute(sql `CREATE INDEX idx_users_plain ON users(id)`)));
156
+ });
157
+ it('does not strip ASYNC outside of CREATE INDEX (column named "async")', async () => {
158
+ await engine.withDdl(() => db.execute(sql `CREATE TABLE jobs (id TEXT PRIMARY KEY, async BOOLEAN)`));
159
+ await db.execute(sql `INSERT INTO jobs (id, async) VALUES (${'j1'}, ${true})`);
160
+ const row = await db.queryOne(sql `SELECT async FROM jobs WHERE id = ${'j1'}`);
161
+ assert.equal(row?.async, true);
162
+ });
163
+ it('does not strip the word ASYNC inside a string literal', async () => {
164
+ await engine.withDdl(() => db.execute(sql `CREATE TABLE notes (id TEXT PRIMARY KEY, body TEXT)`));
165
+ await db.execute(sql `INSERT INTO notes (id, body) VALUES (${'n1'}, ${'run ASYNC index later'})`);
166
+ const row = await db.queryOne(sql `SELECT body FROM notes WHERE id = ${'n1'}`);
167
+ assert.equal(row?.body, 'run ASYNC index later');
168
+ });
169
+ it('runs migrations that use CREATE INDEX ASYNC', async () => {
170
+ const DIR4 = '.bb-data/__test_dsql_async_index_mig__';
171
+ rmSync(DIR4, { recursive: true, force: true });
172
+ const mengine = new DsqlMockEngine(DIR4);
173
+ try {
174
+ const applied = await mengine.withDdl(() => runMigrations(mengine, {
175
+ '001_create_posts.sql': 'CREATE TABLE posts (id TEXT PRIMARY KEY, slug TEXT)',
176
+ '002_index_slug.sql': 'CREATE INDEX ASYNC idx_posts_slug ON posts(slug)',
177
+ }));
178
+ assert.deepEqual(applied, ['001_create_posts.sql', '002_index_slug.sql']);
179
+ }
180
+ finally {
181
+ await mengine.destroy();
182
+ rmSync(DIR4, { recursive: true, force: true });
183
+ }
184
+ });
185
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=e2e.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"e2e.test.d.ts","sourceRoot":"","sources":["../src/e2e.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,329 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * E2E tests for the DistributedDatabase public API.
5
+ *
6
+ * Exercises the full user-facing surface: DistributedDatabase class with migrations,
7
+ * CRUD, transactions, OCC retry, and DSQL validation guardrails.
8
+ *
9
+ * Runs against the mock engine (PGlite) locally. The same test scenarios
10
+ * would apply against a real DSQL cluster — swap the import condition to
11
+ * 'aws-runtime' and provide DSQL_ENDPOINT / AWS_REGION env vars.
12
+ */
13
+ import { describe, it, before, after } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { rmSync, mkdirSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { DistributedDatabase, sql, DistributedDatabaseErrors } from './index.mock.js';
18
+ // ── Helpers ─────────────────────────────────────────────────────────────────
19
+ const TEST_DIR = '.bb-data/__test_dsql_public_api__';
20
+ const MIGRATIONS_DIR = join(TEST_DIR, 'migrations');
21
+ /** Minimal scope stub — DistributedDatabase extends Scope which needs a parent. */
22
+ const scope = { id: 'test' };
23
+ function uniqueId() {
24
+ return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
25
+ }
26
+ // ── Setup migrations on disk ────────────────────────────────────────────────
27
+ function writeMigrations(files) {
28
+ rmSync(MIGRATIONS_DIR, { recursive: true, force: true });
29
+ mkdirSync(MIGRATIONS_DIR, { recursive: true });
30
+ for (const [name, content] of Object.entries(files)) {
31
+ writeFileSync(join(MIGRATIONS_DIR, name), content);
32
+ }
33
+ }
34
+ // ═══════════════════════════════════════════════════════════════════════════════
35
+ // Test Suite: DistributedDatabase with migrations
36
+ // ═══════════════════════════════════════════════════════════════════════════════
37
+ describe('DistributedDatabase — public API with migrations', () => {
38
+ let db;
39
+ before(() => {
40
+ rmSync(TEST_DIR, { recursive: true, force: true });
41
+ writeMigrations({
42
+ '001_create_users.sql': `CREATE TABLE users (
43
+ id TEXT PRIMARY KEY,
44
+ name TEXT NOT NULL,
45
+ email TEXT UNIQUE NOT NULL
46
+ )`,
47
+ '002_create_accounts.sql': `CREATE TABLE accounts (
48
+ id TEXT PRIMARY KEY,
49
+ owner_id TEXT NOT NULL,
50
+ balance INT NOT NULL DEFAULT 0
51
+ )`,
52
+ '003_seed_admin.sql': `INSERT INTO users (id, name, email) VALUES ('admin', 'Admin', 'admin@test.com')`,
53
+ });
54
+ db = new DistributedDatabase(scope, 'e2e', { migrationsPath: MIGRATIONS_DIR });
55
+ });
56
+ after(async () => {
57
+ await db.mockEngine.destroy();
58
+ rmSync(TEST_DIR, { recursive: true, force: true });
59
+ });
60
+ // ── Migrations ──────────────────────────────────────────────────────────
61
+ describe('migrations', () => {
62
+ it('applies migrations and seeds data on first query', async () => {
63
+ const admin = await db.queryOne(sql `SELECT name, email FROM users WHERE id = ${'admin'}`);
64
+ assert.equal(admin?.name, 'Admin');
65
+ assert.equal(admin?.email, 'admin@test.com');
66
+ });
67
+ it('created all tables from migrations', async () => {
68
+ // Verify accounts table exists by inserting
69
+ const { rowCount } = await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${'acc-check'}, ${'admin'}, ${0})`);
70
+ assert.equal(rowCount, 1);
71
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${'acc-check'}`);
72
+ });
73
+ });
74
+ // ── CRUD Operations ─────────────────────────────────────────────────────
75
+ describe('CRUD', () => {
76
+ it('insert and query', async () => {
77
+ const id = uniqueId();
78
+ const { rowCount } = await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id}, ${'Alice'}, ${`alice-${id}@test.com`})`);
79
+ assert.equal(rowCount, 1);
80
+ const rows = await db.query(sql `SELECT id, name FROM users WHERE id = ${id}`);
81
+ assert.equal(rows.length, 1);
82
+ assert.equal(rows[0].name, 'Alice');
83
+ await db.execute(sql `DELETE FROM users WHERE id = ${id}`);
84
+ });
85
+ it('queryOne returns row or null', async () => {
86
+ const id = uniqueId();
87
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id}, ${'Bob'}, ${`bob-${id}@test.com`})`);
88
+ const found = await db.queryOne(sql `SELECT name FROM users WHERE id = ${id}`);
89
+ assert.equal(found?.name, 'Bob');
90
+ const missing = await db.queryOne(sql `SELECT * FROM users WHERE id = ${'nonexistent'}`);
91
+ assert.equal(missing, null);
92
+ await db.execute(sql `DELETE FROM users WHERE id = ${id}`);
93
+ });
94
+ it('update returns affected row count', async () => {
95
+ const id = uniqueId();
96
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id}, ${'Carol'}, ${`carol-${id}@test.com`})`);
97
+ const { rowCount } = await db.execute(sql `UPDATE users SET name = ${'Carol2'} WHERE id = ${id}`);
98
+ assert.equal(rowCount, 1);
99
+ const updated = await db.queryOne(sql `SELECT name FROM users WHERE id = ${id}`);
100
+ assert.equal(updated?.name, 'Carol2');
101
+ await db.execute(sql `DELETE FROM users WHERE id = ${id}`);
102
+ });
103
+ it('delete returns affected row count', async () => {
104
+ const id = uniqueId();
105
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id}, ${'Del'}, ${`del-${id}@test.com`})`);
106
+ const { rowCount } = await db.execute(sql `DELETE FROM users WHERE id = ${id}`);
107
+ assert.equal(rowCount, 1);
108
+ const gone = await db.queryOne(sql `SELECT * FROM users WHERE id = ${id}`);
109
+ assert.equal(gone, null);
110
+ });
111
+ it('query with multiple results ordered', async () => {
112
+ const ids = [uniqueId(), uniqueId(), uniqueId()];
113
+ for (const [i, id] of ids.entries()) {
114
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id}, ${`User${i}`}, ${`u${i}-${id}@test.com`})`);
115
+ }
116
+ const rows = await db.query(sql `SELECT name FROM users WHERE id = ANY(${ids}) ORDER BY name`);
117
+ assert.equal(rows.length, 3);
118
+ assert.equal(rows[0].name, 'User0');
119
+ assert.equal(rows[2].name, 'User2');
120
+ for (const id of ids)
121
+ await db.execute(sql `DELETE FROM users WHERE id = ${id}`);
122
+ });
123
+ });
124
+ // ── Transactions ────────────────────────────────────────────────────────
125
+ describe('transactions', () => {
126
+ it('commits atomically', async () => {
127
+ const a = uniqueId();
128
+ const b = uniqueId();
129
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${a}, ${'x'}, ${1000})`);
130
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${b}, ${'y'}, ${500})`);
131
+ await db.transaction(async (tx) => {
132
+ await tx.execute(sql `UPDATE accounts SET balance = balance - ${200} WHERE id = ${a}`);
133
+ await tx.execute(sql `UPDATE accounts SET balance = balance + ${200} WHERE id = ${b}`);
134
+ });
135
+ const accA = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${a}`);
136
+ const accB = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${b}`);
137
+ assert.equal(accA?.balance, 800);
138
+ assert.equal(accB?.balance, 700);
139
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${a}`);
140
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${b}`);
141
+ });
142
+ it('rolls back on error — no partial writes', async () => {
143
+ const id = uniqueId();
144
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${id}, ${'z'}, ${100})`);
145
+ await assert.rejects(() => db.transaction(async (tx) => {
146
+ await tx.execute(sql `UPDATE accounts SET balance = ${0} WHERE id = ${id}`);
147
+ throw new Error('Simulated failure');
148
+ }), /Simulated failure/);
149
+ const acc = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${id}`);
150
+ assert.equal(acc?.balance, 100); // unchanged
151
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${id}`);
152
+ });
153
+ it('returns value from transaction callback', async () => {
154
+ const id = uniqueId();
155
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${id}, ${'w'}, ${42})`);
156
+ const result = await db.transaction(async (tx) => {
157
+ const row = await tx.queryOne(sql `SELECT balance FROM accounts WHERE id = ${id}`);
158
+ return row.balance * 2;
159
+ });
160
+ assert.equal(result, 84);
161
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${id}`);
162
+ });
163
+ });
164
+ // ── OCC Conflict & Retry ───────────────────────────────────────────────
165
+ describe('OCC conflict handling', () => {
166
+ it('throws SerializationFailureException without retry', async () => {
167
+ const id = uniqueId();
168
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${id}, ${'occ'}, ${50})`);
169
+ db.simulateConflict();
170
+ await assert.rejects(() => db.transaction(async (tx) => {
171
+ await tx.execute(sql `UPDATE accounts SET balance = ${99} WHERE id = ${id}`);
172
+ }), (e) => {
173
+ assert.equal(e.name, DistributedDatabaseErrors.SerializationFailure);
174
+ return true;
175
+ });
176
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${id}`);
177
+ });
178
+ it('retryOnConflict recovers transparently', async () => {
179
+ const id = uniqueId();
180
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${id}, ${'occ2'}, ${10})`);
181
+ db.simulateConflict();
182
+ const result = await db.transaction(async (tx) => {
183
+ await tx.execute(sql `UPDATE accounts SET balance = balance + ${5} WHERE id = ${id}`);
184
+ return 'done';
185
+ }, { retryOnConflict: true });
186
+ assert.equal(result, 'done');
187
+ const acc = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${id}`);
188
+ assert.equal(acc?.balance, 15);
189
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${id}`);
190
+ });
191
+ it('respects maxRetries limit', async () => {
192
+ const id = uniqueId();
193
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${id}, ${'occ3'}, ${1})`);
194
+ // Simulate conflict on every attempt — should exhaust retries
195
+ let attempts = 0;
196
+ const origSimulate = db.simulateConflict.bind(db);
197
+ await assert.rejects(() => db.transaction(async (tx) => {
198
+ attempts++;
199
+ db.simulateConflict(); // re-arm for next attempt
200
+ await tx.execute(sql `UPDATE accounts SET balance = ${99} WHERE id = ${id}`);
201
+ }, { retryOnConflict: true, maxRetries: 2 }), (e) => {
202
+ assert.equal(e.name, DistributedDatabaseErrors.SerializationFailure);
203
+ return true;
204
+ });
205
+ // 1 initial + 2 retries = 3 attempts
206
+ assert.equal(attempts, 3);
207
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${id}`);
208
+ });
209
+ });
210
+ // ── DSQL Validation Guardrails ─────────────────────────────────────────
211
+ describe('DSQL validation at query time', () => {
212
+ it('rejects FOREIGN KEY', async () => {
213
+ await assert.rejects(() => db.execute(sql `CREATE TABLE bad_fk (id TEXT, ref TEXT REFERENCES users(id))`), { name: 'DsqlValidationError' });
214
+ });
215
+ it('rejects SERIAL/BIGSERIAL', async () => {
216
+ await assert.rejects(() => db.execute(sql `CREATE TABLE bad_serial (id SERIAL PRIMARY KEY)`), { name: 'DsqlValidationError' });
217
+ });
218
+ it('rejects TRUNCATE', async () => {
219
+ await assert.rejects(() => db.execute(sql `TRUNCATE TABLE users`), { name: 'DsqlValidationError' });
220
+ });
221
+ it('rejects CREATE VIEW', async () => {
222
+ await assert.rejects(() => db.execute(sql `CREATE VIEW user_names AS SELECT name FROM users`), { name: 'DsqlValidationError' });
223
+ });
224
+ it('rejects CREATE TRIGGER', async () => {
225
+ await assert.rejects(() => db.execute(sql `CREATE TRIGGER t BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION f()`), { name: 'DsqlValidationError' });
226
+ });
227
+ it('rejects TEMP TABLE', async () => {
228
+ await assert.rejects(() => db.execute(sql `CREATE TEMP TABLE staging (id TEXT)`), { name: 'DsqlValidationError' });
229
+ });
230
+ // DDL is rejected outright in the app runtime (parity with prod
231
+ // dsql:DbConnect, which is DML-only), so any CREATE inside a normal
232
+ // transaction surfaces as DsqlPermissionError before the DDL/DML mixing
233
+ // rule is ever evaluated.
234
+ it('rejects DDL + DML in same transaction', async () => {
235
+ await assert.rejects(() => db.transaction(async (tx) => {
236
+ await tx.execute(sql `INSERT INTO users (id, name, email) VALUES (${'x'}, ${'X'}, ${'x@t.com'})`);
237
+ await tx.execute(sql `CREATE TABLE should_fail (id TEXT PRIMARY KEY)`);
238
+ }), { name: 'DsqlPermissionError' });
239
+ });
240
+ it('rejects multiple DDL in same transaction', async () => {
241
+ await assert.rejects(() => db.transaction(async (tx) => {
242
+ await tx.execute(sql `CREATE TABLE t1 (id TEXT PRIMARY KEY)`);
243
+ await tx.execute(sql `CREATE TABLE t2 (id TEXT PRIMARY KEY)`);
244
+ }), { name: 'DsqlPermissionError' });
245
+ });
246
+ });
247
+ // ── Error Translation ──────────────────────────────────────────────────
248
+ describe('error translation', () => {
249
+ it('unique constraint violation → UniqueConstraintViolationException', async () => {
250
+ const id = uniqueId();
251
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id}, ${'First'}, ${`dup-${id}@test.com`})`);
252
+ await assert.rejects(() => db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id}, ${'Second'}, ${`dup2-${id}@test.com`})`), (e) => {
253
+ assert.equal(e.name, DistributedDatabaseErrors.UniqueConstraintViolation);
254
+ return true;
255
+ });
256
+ await db.execute(sql `DELETE FROM users WHERE id = ${id}`);
257
+ });
258
+ it('unique constraint on email column', async () => {
259
+ const id1 = uniqueId();
260
+ const id2 = uniqueId();
261
+ const email = `shared-${uniqueId()}@test.com`;
262
+ await db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id1}, ${'A'}, ${email})`);
263
+ await assert.rejects(() => db.execute(sql `INSERT INTO users (id, name, email) VALUES (${id2}, ${'B'}, ${email})`), (e) => {
264
+ assert.equal(e.name, DistributedDatabaseErrors.UniqueConstraintViolation);
265
+ return true;
266
+ });
267
+ await db.execute(sql `DELETE FROM users WHERE id = ${id1}`);
268
+ });
269
+ });
270
+ // ── Concurrent-style Scenarios ─────────────────────────────────────────
271
+ describe('realistic scenarios', () => {
272
+ it('bank transfer — debit and credit are atomic', async () => {
273
+ const alice = uniqueId();
274
+ const bob = uniqueId();
275
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${alice}, ${'alice'}, ${1000})`);
276
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${bob}, ${'bob'}, ${200})`);
277
+ await db.transaction(async (tx) => {
278
+ const sender = await tx.queryOne(sql `SELECT balance FROM accounts WHERE id = ${alice}`);
279
+ if (!sender || sender.balance < 300)
280
+ throw new Error('Insufficient funds');
281
+ await tx.execute(sql `UPDATE accounts SET balance = balance - ${300} WHERE id = ${alice}`);
282
+ await tx.execute(sql `UPDATE accounts SET balance = balance + ${300} WHERE id = ${bob}`);
283
+ });
284
+ const a = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${alice}`);
285
+ const b = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${bob}`);
286
+ assert.equal(a?.balance, 700);
287
+ assert.equal(b?.balance, 500);
288
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${alice}`);
289
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${bob}`);
290
+ });
291
+ it('insufficient funds — transaction aborts cleanly', async () => {
292
+ const acc = uniqueId();
293
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${acc}, ${'poor'}, ${10})`);
294
+ await assert.rejects(() => db.transaction(async (tx) => {
295
+ const row = await tx.queryOne(sql `SELECT balance FROM accounts WHERE id = ${acc}`);
296
+ if (!row || row.balance < 1000)
297
+ throw new Error('Insufficient funds');
298
+ await tx.execute(sql `UPDATE accounts SET balance = balance - ${1000} WHERE id = ${acc}`);
299
+ }), /Insufficient funds/);
300
+ const row = await db.queryOne(sql `SELECT balance FROM accounts WHERE id = ${acc}`);
301
+ assert.equal(row?.balance, 10); // unchanged
302
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${acc}`);
303
+ });
304
+ it('batch insert with unique IDs', async () => {
305
+ const ids = [];
306
+ for (let i = 0; i < 10; i++) {
307
+ const id = uniqueId();
308
+ ids.push(id);
309
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${id}, ${`owner-${i}`}, ${i * 100})`);
310
+ }
311
+ const rows = await db.query(sql `SELECT balance FROM accounts WHERE id = ANY(${ids}) ORDER BY balance`);
312
+ assert.equal(rows.length, 10);
313
+ assert.equal(rows[0].balance, 0);
314
+ assert.equal(rows[9].balance, 900);
315
+ for (const id of ids)
316
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${id}`);
317
+ });
318
+ it('read-after-write consistency within transaction', async () => {
319
+ const id = uniqueId();
320
+ await db.execute(sql `INSERT INTO accounts (id, owner_id, balance) VALUES (${id}, ${'rw'}, ${0})`);
321
+ await db.transaction(async (tx) => {
322
+ await tx.execute(sql `UPDATE accounts SET balance = ${500} WHERE id = ${id}`);
323
+ const row = await tx.queryOne(sql `SELECT balance FROM accounts WHERE id = ${id}`);
324
+ assert.equal(row?.balance, 500); // sees own write
325
+ });
326
+ await db.execute(sql `DELETE FROM accounts WHERE id = ${id}`);
327
+ });
328
+ });
329
+ });
@@ -0,0 +1,26 @@
1
+ import type { DatabaseEngine, TransactionHandle } from '@aws-blocks/data-common';
2
+ export interface DsqlEngineConfig {
3
+ endpoint: string;
4
+ region: string;
5
+ getAuthToken: () => Promise<string>;
6
+ poolSize?: number;
7
+ /** PostgreSQL role name to connect as (mapped from IAM via `AWS IAM GRANT`). */
8
+ role: string;
9
+ }
10
+ export declare class DsqlEngine implements DatabaseEngine {
11
+ private pool;
12
+ constructor(config: DsqlEngineConfig);
13
+ query<T>(sql: string, params?: unknown[]): Promise<T[]>;
14
+ execute(sql: string, params?: unknown[]): Promise<{
15
+ rowCount: number;
16
+ }>;
17
+ beginTransaction(): Promise<TransactionHandle>;
18
+ commitTransaction(handle: TransactionHandle): Promise<void>;
19
+ rollbackTransaction(handle: TransactionHandle): Promise<void>;
20
+ queryInTransaction<T>(handle: TransactionHandle, sql: string, params?: unknown[]): Promise<T[]>;
21
+ executeInTransaction(handle: TransactionHandle, sql: string, params?: unknown[]): Promise<{
22
+ rowCount: number;
23
+ }>;
24
+ destroy(): Promise<void>;
25
+ }
26
+ //# sourceMappingURL=dsql-engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dsql-engine.d.ts","sourceRoot":"","sources":["../../src/engines/dsql-engine.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAIjF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qBAAa,UAAW,YAAW,cAAc;IAC/C,OAAO,CAAC,IAAI,CAAU;gBAEV,MAAM,EAAE,gBAAgB;IAc9B,KAAK,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IASvD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IASvE,gBAAgB,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAY9C,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAY3D,mBAAmB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU7D,kBAAkB,CAAC,CAAC,EAAE,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;IAS/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;IAS/G,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAC/B"}
@@ -0,0 +1,103 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Production DSQL engine — pg.Pool with IAM token authentication.
5
+ */
6
+ import pg from 'pg';
7
+ import { translateDsqlError } from '../errors.js';
8
+ import { DEFAULT_POOL_SIZE } from '../constants.js';
9
+ export class DsqlEngine {
10
+ pool;
11
+ constructor(config) {
12
+ // DSQL clusters have a fixed connection contract: port 5432,
13
+ // database 'postgres', TLS required.
14
+ this.pool = new pg.Pool({
15
+ host: config.endpoint,
16
+ port: 5432,
17
+ user: config.role,
18
+ database: 'postgres',
19
+ ssl: true,
20
+ max: config.poolSize ?? DEFAULT_POOL_SIZE,
21
+ password: config.getAuthToken,
22
+ });
23
+ }
24
+ async query(sql, params) {
25
+ try {
26
+ return (await this.pool.query(sql, params)).rows;
27
+ }
28
+ catch (e) {
29
+ const err = e;
30
+ console.error('[DsqlEngine] query failed', { code: err.code, severity: err.severity });
31
+ translateDsqlError(err);
32
+ }
33
+ }
34
+ async execute(sql, params) {
35
+ try {
36
+ return { rowCount: (await this.pool.query(sql, params)).rowCount ?? 0 };
37
+ }
38
+ catch (e) {
39
+ const err = e;
40
+ console.error('[DsqlEngine] execute failed', { code: err.code, severity: err.severity });
41
+ translateDsqlError(err);
42
+ }
43
+ }
44
+ async beginTransaction() {
45
+ try {
46
+ const client = await this.pool.connect();
47
+ await client.query('BEGIN');
48
+ return client;
49
+ }
50
+ catch (e) {
51
+ const err = e;
52
+ console.error('[DsqlEngine] beginTransaction failed', { code: err.code, severity: err.severity });
53
+ translateDsqlError(err);
54
+ }
55
+ }
56
+ async commitTransaction(handle) {
57
+ const client = handle;
58
+ try {
59
+ await client.query('COMMIT');
60
+ }
61
+ catch (e) {
62
+ client.release();
63
+ const err = e;
64
+ console.error('[DsqlEngine] commitTransaction failed', { code: err.code, severity: err.severity });
65
+ translateDsqlError(err);
66
+ }
67
+ client.release();
68
+ }
69
+ async rollbackTransaction(handle) {
70
+ const client = handle;
71
+ try {
72
+ await client.query('ROLLBACK');
73
+ }
74
+ catch (e) {
75
+ const err = e;
76
+ console.error('[DsqlEngine] rollbackTransaction failed', { code: err.code, severity: err.severity });
77
+ }
78
+ finally {
79
+ client.release();
80
+ }
81
+ }
82
+ async queryInTransaction(handle, sql, params) {
83
+ try {
84
+ return (await handle.query(sql, params)).rows;
85
+ }
86
+ catch (e) {
87
+ const err = e;
88
+ console.error('[DsqlEngine] queryInTransaction failed', { code: err.code, severity: err.severity });
89
+ translateDsqlError(err);
90
+ }
91
+ }
92
+ async executeInTransaction(handle, sql, params) {
93
+ try {
94
+ return { rowCount: (await handle.query(sql, params)).rowCount ?? 0 };
95
+ }
96
+ catch (e) {
97
+ const err = e;
98
+ console.error('[DsqlEngine] executeInTransaction failed', { code: err.code, severity: err.severity });
99
+ translateDsqlError(err);
100
+ }
101
+ }
102
+ async destroy() { await this.pool.end(); }
103
+ }