@aws-blocks/bb-distributed-data 0.1.1 → 0.1.3

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/DESIGN.md ADDED
@@ -0,0 +1,205 @@
1
+ # DistributedDatabase — Design
2
+
3
+ Design document for the DistributedDatabase Building Block. For usage, see [README.md](./README.md).
4
+
5
+ **Package:** `@aws-blocks/bb-distributed-data`
6
+ **Type:** Primitive (new infrastructure)
7
+ **AWS Service:** Amazon Aurora DSQL
8
+
9
+ ## Architecture
10
+
11
+ ```
12
+ data-common (shared abstractions)
13
+ ├── DatabaseEngine interface
14
+ ├── DatabaseBase class
15
+ ├── sql tagged template + SqlQuery
16
+ ├── Kysely adapter
17
+ └── splitStatements()
18
+
19
+ bb-distributed-data (this package)
20
+ ├── DsqlEngine (AWS — pg.Pool + IAM token auth)
21
+ ├── DsqlMockEngine (local — PGlite + validation layer)
22
+ ├── Validation layer (DSQL compatibility checks)
23
+ ├── TransactionTracker (DDL/DML/row-limit enforcement)
24
+ ├── DSQL-specific migration runner
25
+ └── CDK construct (CfnResource + migration CustomResource)
26
+ ```
27
+
28
+ ## Why a Separate Block (Not an Engine Flag on Database)
29
+
30
+ 1. **Transaction semantics differ** — OCC means callbacks may need retry. Different API contract.
31
+ 2. **Feature set is a strict subset** — FK, RLS, triggers, views absent. An engine flag hides this until deploy time.
32
+ 3. **Mock parity goes in opposite directions** — PGlite is too permissive for DSQL. A separate block can have a restrictive mock.
33
+ 4. **"When to use" guidance is completely different** — customers shouldn't accidentally pick DSQL.
34
+ 5. **Multi-region is a first-class capability** — not a bolt-on option.
35
+
36
+ ## Engine Implementations
37
+
38
+ ### DsqlEngine (AWS Runtime)
39
+
40
+ - `pg.Pool` with IAM token authentication via `@aws-sdk/dsql-signer`
41
+ - Password callback generates fresh tokens per connection (60-min expiry)
42
+ - Translates pg error codes to `DistributedDatabaseErrors` names
43
+ - Pool handles reconnection transparently when connections expire
44
+
45
+ ### DsqlMockEngine (Local Dev)
46
+
47
+ - PGlite wrapped with a validation layer
48
+ - `validateStatement()` rejects unsupported SQL before execution
49
+ - `TransactionTracker` enforces DDL/DML separation and 3,000-row limit
50
+ - `simulateConflict()` test helper for OCC testing
51
+ - Error translation matches production behavior
52
+
53
+ ## Validation Layer
54
+
55
+ The core insight: PGlite supports everything DSQL doesn't. Without validation, code works locally but breaks in production — the worst failure mode. The mock actively restricts PGlite to match DSQL's subset.
56
+
57
+ ### Statement Validation
58
+
59
+ `validateStatement(sql)` strips string literals and comments, then checks regex patterns:
60
+
61
+ | Pattern | Rejects |
62
+ |---------|---------|
63
+ | `FOREIGN KEY` / `REFERENCES` | FK constraints |
64
+ | `CREATE TRIGGER` | Triggers |
65
+ | `CREATE VIEW` | Views |
66
+ | `LANGUAGE plpgsql` | PL/pgSQL functions |
67
+ | `SERIAL` / `BIGSERIAL` | Sequences |
68
+ | `TRUNCATE` | Use DELETE FROM |
69
+ | `LISTEN` / `NOTIFY` | Async notifications |
70
+ | `CREATE EXTENSION` | Extensions |
71
+ | `ADD COLUMN ... DEFAULT` | Column default on ALTER |
72
+ | `ALTER DEFAULT PRIVILEGES` | Not supported by DSQL |
73
+ | `CREATE POLICY` / `ENABLE ROW LEVEL SECURITY` | RLS |
74
+ | `CREATE TEMP TABLE` | Temporary tables |
75
+ | `SET TRANSACTION ISOLATION LEVEL` | Fixed Repeatable Read |
76
+ | `COLLATE` | C collation only |
77
+ | `CREATE INDEX ... ASC/DESC` | Sort direction on index keys (NULLS FIRST/LAST is allowed) |
78
+
79
+ ### Transaction Tracking
80
+
81
+ `TransactionTracker` enforces per-transaction constraints:
82
+
83
+ - Max 1 DDL statement per transaction
84
+ - Cannot mix DDL and DML in the same transaction
85
+ - Max 3,000 rows mutated (cumulative across all executes)
86
+
87
+ ### Migration Validation
88
+
89
+ `validateMigrations()` checks all files upfront before running any:
90
+
91
+ - Each file: max 1 DDL statement
92
+ - Each file: no DDL + DML mixing
93
+ - All statements pass `validateStatement()`
94
+
95
+ ## Migration Runner
96
+
97
+ DSQL's migration runner differs from the generic one in `data-common`:
98
+
99
+ - **DDL files** run as implicit transactions (no explicit BEGIN/COMMIT) — DSQL auto-commits DDL
100
+ - **DML files** run in explicit transactions (atomic)
101
+ - `validateMigrations()` runs upfront — catches errors before any SQL executes
102
+ - Uses `gen_random_uuid()` for `_migrations.id` (no SERIAL)
103
+
104
+ ## OCC Retry Logic
105
+
106
+ ```typescript
107
+ async transaction<T>(fn, options?) {
108
+ const maxAttempts = options?.retryOnConflict ? (options.maxRetries ?? 3) + 1 : 1;
109
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
110
+ try {
111
+ return await this.base.transaction(fn);
112
+ } catch (e) {
113
+ const isOcc = e.code === '40001' || e.name === 'SerializationFailureException';
114
+ if (isOcc && attempt < maxAttempts) continue;
115
+ throw e;
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ Default: no retry (honest, predictable). `retryOnConflict: true` is explicit opt-in with JSDoc warning about side effects.
122
+
123
+ ## Error Translation
124
+
125
+ Happens in the engine layer (same pattern as bb-data engines):
126
+
127
+ | pg error code | DistributedDatabaseErrors name |
128
+ |---------------|------------------------|
129
+ | `40001` | `SerializationFailure` |
130
+ | `23505` | `UniqueConstraintViolation` |
131
+ | `08xxx` | `ConnectionFailed` |
132
+ | (other) | `QueryFailed` |
133
+
134
+ The `DistributedDatabase` class does not wrap errors — engines handle translation.
135
+
136
+ ## Infrastructure (CDK)
137
+
138
+ | Resource | Purpose |
139
+ |----------|---------|
140
+ | `AWS::DSQL::Cluster` (CfnResource) | DSQL cluster |
141
+ | Migration Lambda (NodejsFunction) | Runs .sql files on deploy |
142
+ | CustomResource + Provider | Triggers migration on deploy |
143
+ | IAM PolicyStatement | `dsql:DbConnect` (app Lambda), `dsql:DbConnectAdmin` (migration Lambda) |
144
+ | CfnOutput | Cluster endpoint |
145
+
146
+ ### Deletion Protection
147
+
148
+ `DeletionProtectionEnabled` is computed from `removalPolicy`:
149
+ - `destroy` or `sandboxMode=true` → disabled
150
+ - Otherwise → enabled
151
+
152
+ ### Migration Lambda
153
+
154
+ - Connects via `pg.Client` + `DsqlSigner.getDbConnectAdminAuthToken()`
155
+ - Retries with exponential backoff on transient connection errors (cluster may take a moment after creation)
156
+ - `.sql` files bundled into the Lambda package via CDK `commandHooks`
157
+ - `migrationsHash` property triggers re-invocation when files change
158
+ - **Provisions custom DB role** on every deploy (idempotent):
159
+ 1. `CREATE ROLE "app_role" WITH LOGIN` (if not exists)
160
+ 2. `AWS IAM GRANT "app_role" TO 'arn:aws:iam::...:role/...'` (maps IAM to DB role)
161
+ 3. Per-table `GRANT SELECT, INSERT, UPDATE, DELETE` on user-created tables
162
+
163
+ ### DSQL Permission Model Limitations
164
+
165
+ - `ALTER DEFAULT PRIVILEGES` — not supported (system entity error)
166
+ - `GRANT ... ON ALL TABLES IN SCHEMA public` — not supported (`public` is a system entity)
167
+ - `GRANT USAGE ON SCHEMA public` — not supported (same reason)
168
+ - Handled by enumerating user tables via `pg_tables` and granting DML individually on each deploy
169
+
170
+ ## Mock vs AWS Behavior Differences
171
+
172
+ | Behavior Difference | Impact | Mitigation |
173
+ |------------|--------|------------|
174
+ | No real OCC conflicts | Single-connection PGlite has no concurrency | `simulateConflict()` test helper |
175
+ | PGlite supports JSONB columns | DSQL rejects JSONB as a column type (use JSON instead; JSONB available as runtime cast only) | Validator rejects JSONB in DDL |
176
+ | System collation vs C only | String sorting may differ | Reject explicit COLLATE |
177
+ | No 60-min connection timeout | Dev sessions are short | Document only |
178
+ | No 10 MiB / 5-min tx limits | Impractical to measure locally | Document only |
179
+ | CREATE INDEX ASYNC is synchronous | Index immediately available locally | Log warning |
180
+
181
+ ## Connection Management
182
+
183
+ DSQL uses IAM token authentication:
184
+ - **App Lambda**: `DsqlSigner.getDbConnectAuthToken()` (DML only)
185
+ - **Migration Lambda**: `DsqlSigner.getDbConnectAdminAuthToken()` (DDL)
186
+ - `pg.Pool` with password callback (fresh token per connection)
187
+ - 60-min connection timeout — transparent to Lambda (short-lived)
188
+ - No secrets, no VPC, no proxy needed
189
+
190
+ ## Relationship to data-common
191
+
192
+ `bb-distributed-data` uses `DatabaseBase` from `data-common` directly (no subclass). Error translation is in the engine. This is the cleaner pattern — `bb-data` subclasses `DatabaseBase` only because it adds RLS support.
193
+
194
+ Shared from `data-common`:
195
+ - `DatabaseEngine` / `DatabaseBase` / `TransactionHandle`
196
+ - `sql` / `SqlQuery` / `unwrapQuery`
197
+ - `createKyselyAdapter`
198
+ - `splitStatements`
199
+
200
+ DSQL-specific (not shared):
201
+ - `validateStatement` / `classifyStatement` / `TransactionTracker`
202
+ - `validateMigrations`
203
+ - `DsqlEngine` / `DsqlMockEngine`
204
+ - `DistributedDatabaseErrors`
205
+ - OCC retry logic
package/README.md CHANGED
@@ -6,6 +6,8 @@ Serverless SQL database backed by Amazon Aurora DSQL. Zero-ops, instant provisio
6
6
 
7
7
  **When NOT to use:** If you need foreign keys, Row Level Security, triggers, views, or stored procedures — use `Database` (Aurora). If you need transactions that must not fail at commit under contention — use `Database`. If you're connecting to Supabase — use `Database` with `fromExisting()`.
8
8
 
9
+ > Design & mock parity details: [DESIGN.md](./DESIGN.md)
10
+
9
11
  ## Quick Start
10
12
 
11
13
  ```typescript
@@ -106,6 +108,7 @@ DSQL is a subset of PostgreSQL. The local mock enforces these restrictions so co
106
108
  | LISTEN / NOTIFY | AppSync Events, EventBridge, or polling |
107
109
  | Extensions | Not available |
108
110
  | ADD COLUMN with DEFAULT | Add column without default, handle nulls in app |
111
+ | Index key sort direction (`ASC`/`DESC`) | Omit it; enforce ordering with `ORDER BY` in queries (`NULLS FIRST/LAST` is supported) |
109
112
 
110
113
  ### Transaction Constraints
111
114
 
@@ -154,6 +154,9 @@ describe('DsqlMockEngine — CREATE INDEX ASYNC parity', () => {
154
154
  it('still supports a plain CREATE INDEX (no ASYNC)', async () => {
155
155
  await assert.doesNotReject(() => engine.withDdl(() => db.execute(sql `CREATE INDEX idx_users_plain ON users(id)`)));
156
156
  });
157
+ it('rejects CREATE INDEX ASYNC with a DESC sort order on a key', async () => {
158
+ await assert.rejects(() => engine.withDdl(() => db.execute(sql `CREATE INDEX ASYNC idx_users_email_desc ON users (email DESC)`)), /sort order/i);
159
+ });
157
160
  it('does not strip ASYNC outside of CREATE INDEX (column named "async")', async () => {
158
161
  await engine.withDdl(() => db.execute(sql `CREATE TABLE jobs (id TEXT PRIMARY KEY, async BOOLEAN)`));
159
162
  await db.execute(sql `INSERT INTO jobs (id, async) VALUES (${'j1'}, ${true})`);
@@ -1 +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"}
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;AA6BD,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"}
@@ -80,6 +80,13 @@ const RULES = [
80
80
  { pattern: /\bCOLLATE\b/i, message: 'DSQL only supports C collation.', severity: 'error' },
81
81
  { pattern: /(?<!::)\bJSONB\b/i, message: 'DSQL does not support JSONB columns. Use JSON instead (JSONB is available as a query runtime cast via ::jsonb).', severity: 'error' },
82
82
  { pattern: /(@>|<@|\?\||\?&)/, message: 'JSONB operators lack GIN index acceleration in DSQL.', severity: 'warn' },
83
+ // DSQL rejects a sort direction (ASC/DESC) on index keys — it isn't in the
84
+ // CREATE INDEX ASYNC grammar (only `NULLS FIRST|LAST`, which IS supported, is).
85
+ // `[^;]*` keeps the match inside a single statement; ASC/DESC cannot otherwise
86
+ // appear in a CREATE INDEX. Caveat: stripLiteralsAndComments doesn't strip
87
+ // double-quoted identifiers, so a column literally named "desc"/"asc" would
88
+ // false-positive — niche, and shared with other single-keyword rules here.
89
+ { pattern: /\bCREATE\s+(?:UNIQUE\s+)?INDEX\b[^;]*\b(?:ASC|DESC)\b/i, message: 'DSQL does not support sort order (ASC/DESC) on index keys. Remove it — ordering is enforced by ORDER BY in queries (NULLS FIRST/LAST is allowed). (DSQL: "specifying sort order not supported for index keys")', severity: 'error' },
83
90
  ];
84
91
  /** Validate a SQL statement for DSQL compatibility. Throws on unsupported features. */
85
92
  export function validateStatement(sql) {
@@ -352,3 +352,30 @@ describe('validateStatement — unsupported features after bind parameters', ()
352
352
  assert.doesNotThrow(() => validateStatement("INSERT INTO audit (id, action) VALUES ($1, 'TRUNCATE')"));
353
353
  });
354
354
  });
355
+ describe('validateStatement — index key sort order', () => {
356
+ it('rejects DESC on an index key', () => {
357
+ assert.throws(() => validateStatement('CREATE INDEX idx ON persons (user_id, last_encounter_at DESC)'), /sort order/i);
358
+ });
359
+ it('rejects DESC on a CREATE INDEX ASYNC key', () => {
360
+ assert.throws(() => validateStatement('CREATE INDEX ASYNC idx_persons_user_recent ON persons (user_id, last_encounter_at DESC)'), /sort order/i);
361
+ });
362
+ it('rejects an explicit ASC on an index key', () => {
363
+ assert.throws(() => validateStatement('CREATE INDEX idx ON t (col ASC)'), /sort order/i);
364
+ });
365
+ it('allows NULLS FIRST / NULLS LAST on an index key (supported by DSQL)', () => {
366
+ assert.doesNotThrow(() => validateStatement('CREATE INDEX ASYNC idx ON t (col NULLS FIRST)'));
367
+ assert.doesNotThrow(() => validateStatement('CREATE INDEX ASYNC idx ON t (col NULLS LAST)'));
368
+ });
369
+ it('allows a plain CREATE INDEX without sort order', () => {
370
+ assert.doesNotThrow(() => validateStatement('CREATE INDEX idx ON persons (user_id, last_encounter_at)'));
371
+ });
372
+ it('allows a partial index whose WHERE mentions a column like "description"', () => {
373
+ assert.doesNotThrow(() => validateStatement('CREATE INDEX idx ON t (name) WHERE description IS NOT NULL'));
374
+ });
375
+ it('allows an expression index without sort order', () => {
376
+ assert.doesNotThrow(() => validateStatement('CREATE INDEX idx ON t ((lower(name)))'));
377
+ });
378
+ it('does not flag ORDER BY ... DESC in a SELECT (no false positive outside CREATE INDEX)', () => {
379
+ assert.doesNotThrow(() => validateStatement('SELECT * FROM persons ORDER BY last_encounter_at DESC'));
380
+ });
381
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws-blocks/bb-distributed-data",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "author": "Amazon Web Services",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@aws-blocks/core": "^0.1.1",
31
31
  "@aws-blocks/data-common": "^0.1.1",
32
- "@aws-blocks/bb-logger": "^0.1.1",
32
+ "@aws-blocks/bb-logger": "^0.1.2",
33
33
  "@aws-sdk/dsql-signer": "^3.700.0",
34
34
  "@electric-sql/pglite": "^0.2.0",
35
35
  "pg": "^8.13.0"