@weft-js/db 0.1.0 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @weft-js/db
2
2
 
3
- > **Early release (0.1.0).** APIs will change between minor versions.
3
+ > **Early release (0.1.x).** APIs can change between minor versions.
4
4
 
5
5
  Database layers and list sources for [`@weft-js/start`](../start). Each driver is an optional peer dependency, and each
6
6
  subpath loads only its own.
@@ -21,7 +21,7 @@ this package), `prisma` or `native`.
21
21
  |---|---|
22
22
  | `@weft-js/db/plugin-native/<engine>` | `createDb({ url })`: a `sql` tag over the driver, with no ORM. Values always go to the driver as parameters. `<engine>` is `sqlite`, `postgres` or `mysql` |
23
23
  | `@weft-js/db/plugin-native/auth` | `nativeAuth(db)`: a Better Auth database adapter over that client |
24
- | `@weft-js/db/plugin-prisma` | `withWeft(client, dialect)`: the same Prisma client, with what weft needs to run its migrations and check its tags |
24
+ | `@weft-js/db/plugin-prisma` | `withWeft(client, dialect)`: the same Prisma client, with what Weft needs to run its migrations and check its tags |
25
25
  | `@weft-js/db/sql`, `/kysely`, `/prisma`, `/mongo`, `/dynamodb`, `/firestore` | List sources: the rows behind a `query` that takes `listInput` from `@weft-js/start/list`, paged, filtered, sorted and searched |
26
26
 
27
27
  The layers and the list sources each have a `/plugin` subpath for `weft.config.ts`, so `weft check` and the plugin lock know it's there.
@@ -29,3 +29,10 @@ The layers and the list sources each have a `/plugin` subpath for `weft.config.t
29
29
  ## Docs
30
30
 
31
31
  `weft docs start` covers the database: `ctx.db`, migrations with `weft db generate`, and change feeds.
32
+
33
+ ## Links
34
+
35
+ - [Source](https://github.com/Ravonus/weft/tree/main/packages/db)
36
+ - [Weft on GitHub](https://github.com/Ravonus/weft): the framework, the docs and every package
37
+ - [Changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md)
38
+ - [Issues](https://github.com/Ravonus/weft/issues)
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@weft-js/db",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "weft's data layers and list sources in one package: each driver is an optional peer, and a subpath loads only its own",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/db"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/db#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Ravonus/weft/issues"
14
+ },
6
15
  "type": "module",
7
16
  "exports": {
8
17
  "./sql": "./src/sql.ts",
@@ -30,14 +39,14 @@
30
39
  "@aws-sdk/client-dynamodb": "^3.0.0",
31
40
  "@aws-sdk/lib-dynamodb": "^3.0.0",
32
41
  "@prisma/client": ">=6",
33
- "@weft-js/start": "^0.1.0",
42
+ "@weft-js/start": "^0.1.2",
34
43
  "better-auth": "^1.7.0",
35
44
  "firebase-admin": ">=12",
36
45
  "kysely": "^0.29.0",
37
46
  "mongodb": ">=6",
38
47
  "mysql2": "^3.24.0",
39
48
  "postgres": ">=3",
40
- "@weft-js/core": "^0.1.0"
49
+ "@weft-js/core": "^0.1.2"
41
50
  },
42
51
  "peerDependenciesMeta": {
43
52
  "@aws-sdk/client-dynamodb": {
@@ -83,4 +92,4 @@
83
92
  "README.md",
84
93
  "LICENSE"
85
94
  ]
86
- }
95
+ }
@@ -9,8 +9,16 @@ export const dynamoSource = definePlugin({
9
9
  weft: '*',
10
10
  permissions: { net: ['driver:aws-sdk'] },
11
11
  contribute: () => ({
12
- provides: [provide(slot, { key: 'dynamodb', sides: { server: { module: '@weft-js/db/dynamodb', export: 'adapter' } } })],
13
- agents: { title: 'Lists (dynamodb)', text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back. Cursor mode only, and a sort needs a partition and an index.' },
12
+ provides: [
13
+ provide(slot, {
14
+ key: 'dynamodb',
15
+ sides: { server: { module: '@weft-js/db/dynamodb', export: 'adapter' } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Lists (dynamodb)',
20
+ text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back. Cursor mode only, and a sort needs a partition and an index.',
21
+ },
14
22
  }),
15
23
  })
16
24
 
@@ -9,8 +9,16 @@ export const firestoreSource = definePlugin({
9
9
  weft: '*',
10
10
  permissions: { net: ['driver:firebase-admin'] },
11
11
  contribute: () => ({
12
- provides: [provide(slot, { key: 'firestore', sides: { server: { module: '@weft-js/db/firestore', export: 'adapter' } } })],
13
- agents: { title: 'Lists (firestore)', text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back. Search is by prefix on one field, and only without another sort.' },
12
+ provides: [
13
+ provide(slot, {
14
+ key: 'firestore',
15
+ sides: { server: { module: '@weft-js/db/firestore', export: 'adapter' } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Lists (firestore)',
20
+ text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back. Search is by prefix on one field, and only without another sort.',
21
+ },
14
22
  }),
15
23
  })
16
24
 
@@ -9,8 +9,16 @@ export const kyselySource = definePlugin({
9
9
  weft: '*',
10
10
  permissions: { net: ['driver:kysely'] },
11
11
  contribute: () => ({
12
- provides: [provide(slot, { key: 'kysely', sides: { server: { module: '@weft-js/db/kysely', export: 'adapter' } } })],
13
- agents: { title: 'Lists (kysely)', text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back.' },
12
+ provides: [
13
+ provide(slot, {
14
+ key: 'kysely',
15
+ sides: { server: { module: '@weft-js/db/kysely', export: 'adapter' } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Lists (kysely)',
20
+ text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back.',
21
+ },
14
22
  }),
15
23
  })
16
24
 
package/plugins/mongo.mjs CHANGED
@@ -9,8 +9,16 @@ export const mongoSource = definePlugin({
9
9
  weft: '*',
10
10
  permissions: { net: ['driver:mongodb'] },
11
11
  contribute: () => ({
12
- provides: [provide(slot, { key: 'mongo', sides: { server: { module: '@weft-js/db/mongo', export: 'adapter' } } })],
13
- agents: { title: 'Lists (mongo)', text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back. `watchMongo` turns writes from anywhere into tag pokes.' },
12
+ provides: [
13
+ provide(slot, {
14
+ key: 'mongo',
15
+ sides: { server: { module: '@weft-js/db/mongo', export: 'adapter' } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Lists (mongo)',
20
+ text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back. `watchMongo` turns writes from anywhere into tag pokes.',
21
+ },
14
22
  }),
15
23
  })
16
24
 
@@ -10,9 +10,12 @@ const DIR = 'migrations'
10
10
  function generate(root, name = 'change') {
11
11
  const dir = join(root, DIR)
12
12
  mkdirSync(dir, { recursive: true })
13
- const next = readdirSync(dir).filter(f => f.endsWith('.sql')).length
13
+ const next = readdirSync(dir).filter((f) => f.endsWith('.sql')).length
14
14
  const file = `${String(next).padStart(4, '0')}_${name.replace(/[^\w-]/g, '_')}.sql`
15
- writeFileSync(join(dir, file), '-- Applies on the next request. Migrations run once, in file order.\n')
15
+ writeFileSync(
16
+ join(dir, file),
17
+ '-- Applies on the next request. Migrations run once, in file order.\n',
18
+ )
16
19
  return `${DIR}/${file}`
17
20
  }
18
21
 
@@ -23,8 +26,17 @@ export const native = definePlugin({
23
26
  permissions: { fs: { read: ['migrations/**'], write: ['migrations/**'] } },
24
27
  contribute: () => ({
25
28
  db: { layer: 'native', migrations: DIR, generate },
26
- agents: { title: 'Database (Native)', text: 'No ORM: `ctx.db.sql` is a tagged template, and values are always bound, never concatenated. Rows are plain objects. `ctx.db.transaction(tx => …)` gives `tx.sql`, and `ctx.db.driver` is the real driver. `weft db generate <name>` starts a file in `migrations/`; write the SQL by hand. Validate input with your own Zod schemas.' },
27
- map: [{ want: 'Change the database', call: '`weft db generate <name>`, then write the SQL in `migrations/`', have: 'no ORM' }],
29
+ agents: {
30
+ title: 'Database (Native)',
31
+ text: 'No ORM: `ctx.db.sql` is a tagged template, and values are always bound, never concatenated. Rows are plain objects. `ctx.db.transaction(tx => …)` gives `tx.sql`, and `ctx.db.driver` is the real driver. `weft db generate <name>` starts a file in `migrations/`; write the SQL by hand. Validate input with your own Zod schemas.',
32
+ },
33
+ map: [
34
+ {
35
+ want: 'Change the database',
36
+ call: '`weft db generate <name>`, then write the SQL in `migrations/`',
37
+ have: 'no ORM',
38
+ },
39
+ ],
28
40
  }),
29
41
  })
30
42
 
@@ -11,19 +11,33 @@ const DIR = 'prisma/migrations'
11
11
  function run(root, args) {
12
12
  let bin
13
13
  try {
14
- bin = join(dirname(createRequire(join(root, 'package.json')).resolve('prisma/package.json')), 'build/index.js')
14
+ bin = join(
15
+ dirname(createRequire(join(root, 'package.json')).resolve('prisma/package.json')),
16
+ 'build/index.js',
17
+ )
15
18
  } catch {
16
19
  throw Object.assign(new Error('prisma is not installed in this project'), { code: 'E_USAGE' })
17
20
  }
18
21
  const r = spawnSync(process.execPath, [bin, ...args], { cwd: root, encoding: 'utf8' })
19
- if (r.status !== 0) throw Object.assign(new Error(`prisma ${args[0]} failed:\n${r.stdout}${r.stderr}`), { code: 'E_INTERNAL' })
22
+ if (r.status !== 0)
23
+ throw Object.assign(new Error(`prisma ${args[0]} failed:\n${r.stdout}${r.stderr}`), {
24
+ code: 'E_INTERNAL',
25
+ })
20
26
  return r.stdout
21
27
  }
22
28
 
23
29
  function generate(root, name = 'change') {
24
30
  run(root, ['generate'])
25
- if (existsSync(join(root, DIR))) return run(root, ['migrate', 'dev', '--create-only', '--name', name]).trim()
26
- const sql = run(root, ['migrate', 'diff', '--from-empty', '--to-schema', 'prisma/schema.prisma', '--script'])
31
+ if (existsSync(join(root, DIR)))
32
+ return run(root, ['migrate', 'dev', '--create-only', '--name', name]).trim()
33
+ const sql = run(root, [
34
+ 'migrate',
35
+ 'diff',
36
+ '--from-empty',
37
+ '--to-schema',
38
+ 'prisma/schema.prisma',
39
+ '--script',
40
+ ])
27
41
  const dir = join(root, DIR, '00000000000000_init')
28
42
  mkdirSync(dir, { recursive: true })
29
43
  writeFileSync(join(dir, 'migration.sql'), sql)
@@ -37,8 +51,17 @@ export const prisma = definePlugin({
37
51
  permissions: { fs: { read: ['prisma/**'], write: ['prisma/migrations/**'] }, spawn: ['node'] },
38
52
  contribute: () => ({
39
53
  db: { layer: 'prisma', schema: 'prisma/schema.prisma', migrations: DIR, generate },
40
- agents: { title: 'Database (Prisma)', text: '`ctx.db` is the real Prisma client, so Prisma\'s docs apply. The schema is `prisma/schema.prisma`; after changing it, run `weft db generate <name>`, which updates the client and adds a migration that applies on the next request.' },
41
- map: [{ want: 'Change the database', call: 'Edit `prisma/schema.prisma`, then `weft db generate <name>`', have: 'Prisma' }],
54
+ agents: {
55
+ title: 'Database (Prisma)',
56
+ text: "`ctx.db` is the real Prisma client, so Prisma's docs apply. The schema is `prisma/schema.prisma`; after changing it, run `weft db generate <name>`, which updates the client and adds a migration that applies on the next request.",
57
+ },
58
+ map: [
59
+ {
60
+ want: 'Change the database',
61
+ call: 'Edit `prisma/schema.prisma`, then `weft db generate <name>`',
62
+ have: 'Prisma',
63
+ },
64
+ ],
42
65
  }),
43
66
  })
44
67
 
@@ -9,8 +9,16 @@ export const prismaSource = definePlugin({
9
9
  weft: '*',
10
10
  permissions: { net: ['driver:prisma'] },
11
11
  contribute: () => ({
12
- provides: [provide(slot, { key: 'prisma', sides: { server: { module: '@weft-js/db/prisma', export: 'adapter' } } })],
13
- agents: { title: 'Lists (prisma)', text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back.' },
12
+ provides: [
13
+ provide(slot, {
14
+ key: 'prisma',
15
+ sides: { server: { module: '@weft-js/db/prisma', export: 'adapter' } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Lists (prisma)',
20
+ text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back.',
21
+ },
14
22
  }),
15
23
  })
16
24
 
package/plugins/sql.mjs CHANGED
@@ -9,8 +9,16 @@ export const sqlSource = definePlugin({
9
9
  weft: '*',
10
10
  permissions: { net: ['driver:query'] },
11
11
  contribute: () => ({
12
- provides: [provide(slot, { key: 'sql', sides: { server: { module: '@weft-js/db/sql', export: 'adapter' } } })],
13
- agents: { title: 'Lists (sql)', text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back.' },
12
+ provides: [
13
+ provide(slot, {
14
+ key: 'sql',
15
+ sides: { server: { module: '@weft-js/db/sql', export: 'adapter' } },
16
+ }),
17
+ ],
18
+ agents: {
19
+ title: 'Lists (sql)',
20
+ text: 'Build a source once, in a server module, with `columns`: only those are selected, returned or written. Then `query({ input: listInput({ sort, filter, search }), run: ({ input }) => source.list(input) })`. warp `Table` reads what comes back.',
21
+ },
14
22
  }),
15
23
  })
16
24
 
package/src/driver.ts CHANGED
@@ -1,12 +1,15 @@
1
- // Each driver is an optional peer: nobody installs Prisma or Mongo to use SQLite. A subpath that needs one loads it here,
2
- // at the top of its own module, so a missing driver says what to install instead of failing somewhere in a bundle.
1
+ // Optional peers load at the subpath so missing drivers report what to install.
3
2
 
4
3
  const MISSING = new Set(['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'])
5
4
 
6
5
  /** True when this failure is `pkg` itself not being there, not something inside it failing to load. */
7
6
  const isMissing = (error: unknown, pkg: string) => {
8
7
  const { code = '', message } = (error ?? {}) as { code?: string; message?: unknown }
9
- return (MISSING.has(code) || /cannot find (module|package)|failed to (load|resolve)/i.test(String(message))) && String(message).includes(pkg)
8
+ return (
9
+ (MISSING.has(code) ||
10
+ /cannot find (module|package)|failed to (load|resolve)/i.test(String(message))) &&
11
+ String(message).includes(pkg)
12
+ )
10
13
  }
11
14
 
12
15
  /** `await driver('@weft-js/db/kysely', 'kysely', () => import('kysely'))`. The import stays literal so a bundler still sees it. */
@@ -14,7 +17,11 @@ export async function driver<T>(subpath: string, pkg: string, load: () => Promis
14
17
  try {
15
18
  return await load()
16
19
  } catch (error) {
17
- if (isMissing(error, pkg)) throw new Error(`${subpath} needs the \`${pkg}\` driver, which isn't installed. Run: npm install ${pkg}`, { cause: error })
20
+ if (isMissing(error, pkg))
21
+ throw new Error(
22
+ `${subpath} needs the \`${pkg}\` driver, which isn't installed. Run: npm install ${pkg}`,
23
+ { cause: error },
24
+ )
18
25
  throw error
19
26
  }
20
27
  }
package/src/dynamodb.ts CHANGED
@@ -1,19 +1,42 @@
1
1
  // A list source over a DynamoDB table, through a `DynamoDBDocumentClient`. DynamoDB has no offset and sorts only inside a
2
2
  // partition, so this is cursor mode only: a `Scan` by default, and a `Query` when `partition` and `sortKey` name an index.
3
3
 
4
- import { pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
4
+ import {
5
+ pageOf,
6
+ plan,
7
+ rowOf,
8
+ storedId,
9
+ written,
10
+ type DbAdapter,
11
+ type ListParams,
12
+ type ListSource,
13
+ type SourceSpec,
14
+ } from '@weft-js/start/list'
5
15
  import { StartError } from '@weft-js/start/data'
6
16
  import { driver } from './driver'
7
17
 
8
- const { DeleteCommand, GetCommand, PutCommand, QueryCommand, ScanCommand, UpdateCommand } = await driver('@weft-js/db/dynamodb', '@aws-sdk/lib-dynamodb', () => import('@aws-sdk/lib-dynamodb'))
18
+ const { DeleteCommand, GetCommand, PutCommand, QueryCommand, ScanCommand, UpdateCommand } =
19
+ await driver(
20
+ '@weft-js/db/dynamodb',
21
+ '@aws-sdk/lib-dynamodb',
22
+ () => import('@aws-sdk/lib-dynamodb'),
23
+ )
9
24
 
10
25
  type Item = Record<string, unknown>
11
26
 
12
27
  /** What a command answers, as far as a source reads it. */
13
- export interface DynamoReply { Item?: Item | undefined; Items?: Item[] | undefined; Attributes?: Item | undefined; LastEvaluatedKey?: Item | undefined }
28
+ export interface DynamoReply {
29
+ Item?: Item | undefined
30
+ Items?: Item[] | undefined
31
+ Attributes?: Item | undefined
32
+ LastEvaluatedKey?: Item | undefined
33
+ }
14
34
 
15
35
  /** A `DynamoDBDocumentClient`, or anything else that sends these commands, and the table to send them to. */
16
- export interface DynamoTarget { client: { send(command: object): Promise<DynamoReply> }; table: string }
36
+ export interface DynamoTarget {
37
+ client: { send(command: object): Promise<DynamoReply> }
38
+ table: string
39
+ }
17
40
 
18
41
  /** A source's allowlist, and the index that lets a list sort. */
19
42
  export interface DynamoSpec extends SourceSpec {
@@ -27,62 +50,134 @@ export interface DynamoSpec extends SourceSpec {
27
50
  const CONDITION = 'ConditionalCheckFailedException'
28
51
  const PAGE = 1 << 12
29
52
 
53
+ type DynamoList<Row> = {
54
+ client: DynamoTarget['client']
55
+ table: string
56
+ spec: DynamoSpec
57
+ idColumn: string
58
+ sortColumn: string | undefined
59
+ projection: string
60
+ names: Record<string, string>
61
+ reading: (row: object) => Row
62
+ idField: string
63
+ }
64
+
65
+ async function listRows<Row>(p: ListParams, context: DynamoList<Row>) {
66
+ const { client, table, spec, idColumn, sortColumn, projection, names, reading, idField } = context
67
+ if (p.mode === 'page')
68
+ throw new StartError('Q002', 'DynamoDB has no offset', "ask for `mode: 'cursor'`")
69
+ const q = plan(spec, p)
70
+ if (q.sort && q.sort.field !== spec.sortKey)
71
+ throw new StartError(
72
+ 'Q002',
73
+ `DynamoDB can sort only on the index's key, not \`${q.sort.field}\``,
74
+ 'set `sortKey`',
75
+ )
76
+ const values: Record<string, unknown> = {},
77
+ ExpressionAttributeNames: Record<string, string> = { ...names }
78
+ let n = 0
79
+ const value = (v: unknown) => {
80
+ const k = `:v${n++}`
81
+ values[k] = v
82
+ return k
83
+ }
84
+ const name = (c: string) => {
85
+ const k = `#n${n++}`
86
+ ExpressionAttributeNames[k] = c
87
+ return k
88
+ }
89
+ const parts = q.where.map((w) => {
90
+ const col = name(w.column)
91
+ return `(${w.values.map((v) => (v === null ? `attribute_not_exists(${col})` : `${col} = ${value(v)}`)).join(' or ')})`
92
+ })
93
+ if (q.search) {
94
+ const v = value(q.search.text)
95
+ parts.push(`(${q.search.columns.map((c) => `contains(${name(c)}, ${v})`).join(' or ')})`)
96
+ }
97
+ const query = spec.partition && sortColumn
98
+ const KeyConditionExpression = query
99
+ ? `${name(spec.partition!.column)} = ${value(spec.partition!.value)}`
100
+ : undefined
101
+ const start = p.after
102
+ ? {
103
+ [idColumn]: p.after[1],
104
+ ...(query
105
+ ? { [spec.partition!.column]: spec.partition!.value, [sortColumn]: p.after[0] }
106
+ : {}),
107
+ }
108
+ : undefined
109
+ const rows: Item[] = []
110
+ let ExclusiveStartKey: Item | undefined = start
111
+ // `Limit` counts what's read before the filter, so a page can take several trips.
112
+ do {
113
+ const input = {
114
+ TableName: table,
115
+ ProjectionExpression: projection,
116
+ ExpressionAttributeNames,
117
+ ...(Object.keys(values).length ? { ExpressionAttributeValues: values } : {}),
118
+ ...(parts.length ? { FilterExpression: parts.join(' and ') } : {}),
119
+ ...(ExclusiveStartKey ? { ExclusiveStartKey } : {}),
120
+ Limit: PAGE,
121
+ }
122
+ const r: DynamoReply = await client.send(
123
+ query
124
+ ? new QueryCommand({
125
+ ...input,
126
+ IndexName: spec.index,
127
+ KeyConditionExpression,
128
+ ScanIndexForward: q.sort?.dir !== 'desc',
129
+ })
130
+ : new ScanCommand(input),
131
+ )
132
+ rows.push(...(r.Items ?? []))
133
+ ExclusiveStartKey = r.LastEvaluatedKey
134
+ } while (ExclusiveStartKey && rows.length <= p.limit)
135
+ return pageOf(rows.slice(0, p.limit + 1).map(reading), p, {
136
+ sort: q.sort?.field ?? spec.sortKey,
137
+ id: idField,
138
+ })
139
+ }
140
+
30
141
  /** A `ListSource` over one table, in cursor mode. */
31
- export function dynamoSource<Row = Record<string, unknown>>({ client, table }: DynamoTarget, spec: DynamoSpec): ListSource<Row> {
142
+ export function dynamoSource<Row = Record<string, unknown>>(
143
+ { client, table }: DynamoTarget,
144
+ spec: DynamoSpec,
145
+ ): ListSource<Row> {
32
146
  const reading = rowOf<Row>(spec)
33
- const idField = spec.id ?? 'id', idColumn = storedId(spec)
147
+ const idField = spec.id ?? 'id',
148
+ idColumn = storedId(spec)
34
149
  const key = (id: unknown) => ({ [idColumn]: id })
35
150
  const sortColumn = spec.sortKey && spec.columns[spec.sortKey]
36
151
  const stored = Object.values(spec.columns)
37
152
  const projection = stored.map((_, i) => `#c${i}`).join(', ')
38
153
  const names = Object.fromEntries(stored.map((c, i) => [`#c${i}`, c]))
39
154
  const byId = async (id: unknown) => {
40
- const r = await client.send(new GetCommand({ TableName: table, Key: key(id), ConsistentRead: true }))
155
+ const r = await client.send(
156
+ new GetCommand({ TableName: table, Key: key(id), ConsistentRead: true }),
157
+ )
41
158
  return r.Item ? reading(r.Item) : null
42
159
  }
43
160
  return {
44
161
  limits: { page: false, search: 'cased', sort: spec.sortKey ? [spec.sortKey] : [] },
45
- async list(p: ListParams) {
46
- if (p.mode === 'page') throw new StartError('Q002', 'DynamoDB has no offset', 'ask for `mode: \'cursor\'`')
47
- const q = plan(spec, p)
48
- if (q.sort && q.sort.field !== spec.sortKey) throw new StartError('Q002', `DynamoDB can sort only on the index's key, not \`${q.sort.field}\``, 'set `sortKey`')
49
- const values: Record<string, unknown> = {}, ExpressionAttributeNames: Record<string, string> = { ...names }
50
- let n = 0
51
- const value = (v: unknown) => { const k = `:v${n++}`; values[k] = v; return k }
52
- const name = (c: string) => { const k = `#n${n++}`; ExpressionAttributeNames[k] = c; return k }
53
- const parts = q.where.map(w => {
54
- const col = name(w.column)
55
- return `(${w.values.map(v => (v === null ? `attribute_not_exists(${col})` : `${col} = ${value(v)}`)).join(' or ')})`
162
+ list(p: ListParams) {
163
+ return listRows(p, {
164
+ client,
165
+ table,
166
+ spec,
167
+ idColumn,
168
+ sortColumn,
169
+ projection,
170
+ names,
171
+ reading,
172
+ idField,
56
173
  })
57
- if (q.search) {
58
- const v = value(q.search.text)
59
- parts.push(`(${q.search.columns.map(c => `contains(${name(c)}, ${v})`).join(' or ')})`)
60
- }
61
- const query = spec.partition && sortColumn
62
- const KeyConditionExpression = query ? `${name(spec.partition!.column)} = ${value(spec.partition!.value)}` : undefined
63
- const start = p.after ? { [idColumn]: p.after[1], ...(query ? { [spec.partition!.column]: spec.partition!.value, [sortColumn]: p.after[0] } : {}) } : undefined
64
- const rows: Item[] = []
65
- let ExclusiveStartKey: Item | undefined = start
66
- // `Limit` counts what's read before the filter, so a page can take several trips.
67
- do {
68
- const input = {
69
- TableName: table,
70
- ProjectionExpression: projection,
71
- ExpressionAttributeNames,
72
- ...(Object.keys(values).length ? { ExpressionAttributeValues: values } : {}),
73
- ...(parts.length ? { FilterExpression: parts.join(' and ') } : {}),
74
- ...(ExclusiveStartKey ? { ExclusiveStartKey } : {}),
75
- Limit: PAGE,
76
- }
77
- const r: DynamoReply = await client.send(query ? new QueryCommand({ ...input, IndexName: spec.index, KeyConditionExpression, ScanIndexForward: q.sort?.dir !== 'desc' }) : new ScanCommand(input))
78
- rows.push(...(r.Items ?? []))
79
- ExclusiveStartKey = r.LastEvaluatedKey
80
- } while (ExclusiveStartKey && rows.length <= p.limit)
81
- return pageOf(rows.slice(0, p.limit + 1).map(reading), p, { sort: q.sort?.field ?? spec.sortKey, id: idField })
82
174
  },
83
175
  get: byId,
84
176
  async create(data) {
85
- const Item = { ...written(spec, data, true), ...(spec.partition ? { [spec.partition.column]: spec.partition.value } : {}) }
177
+ const Item = {
178
+ ...written(spec, data, true),
179
+ ...(spec.partition ? { [spec.partition.column]: spec.partition.value } : {}),
180
+ }
86
181
  await client.send(new PutCommand({ TableName: table, Item }))
87
182
  return reading(Item)
88
183
  },
@@ -90,10 +185,24 @@ export function dynamoSource<Row = Record<string, unknown>>({ client, table }: D
90
185
  const row = written(spec, patch)
91
186
  const keys = Object.keys(row)
92
187
  if (!keys.length) return byId(id)
93
- const ExpressionAttributeNames: Record<string, string> = { '#id': idColumn }, ExpressionAttributeValues: Record<string, unknown> = {}
94
- keys.forEach((k, i) => { ExpressionAttributeNames[`#k${i}`] = k; ExpressionAttributeValues[`:k${i}`] = row[k] })
188
+ const ExpressionAttributeNames: Record<string, string> = { '#id': idColumn },
189
+ ExpressionAttributeValues: Record<string, unknown> = {}
190
+ keys.forEach((k, i) => {
191
+ ExpressionAttributeNames[`#k${i}`] = k
192
+ ExpressionAttributeValues[`:k${i}`] = row[k]
193
+ })
95
194
  try {
96
- const r = await client.send(new UpdateCommand({ TableName: table, Key: key(id), ConditionExpression: 'attribute_exists(#id)', UpdateExpression: `set ${keys.map((_, i) => `#k${i} = :k${i}`).join(', ')}`, ExpressionAttributeNames, ExpressionAttributeValues, ReturnValues: 'ALL_NEW' }))
195
+ const r = await client.send(
196
+ new UpdateCommand({
197
+ TableName: table,
198
+ Key: key(id),
199
+ ConditionExpression: 'attribute_exists(#id)',
200
+ UpdateExpression: `set ${keys.map((_, i) => `#k${i} = :k${i}`).join(', ')}`,
201
+ ExpressionAttributeNames,
202
+ ExpressionAttributeValues,
203
+ ReturnValues: 'ALL_NEW',
204
+ }),
205
+ )
97
206
  return reading(r.Attributes!)
98
207
  } catch (e) {
99
208
  if ((e as { name?: string } | null)?.name === CONDITION) return null
@@ -101,10 +210,17 @@ export function dynamoSource<Row = Record<string, unknown>>({ client, table }: D
101
210
  }
102
211
  },
103
212
  async remove(id) {
104
- return !!(await client.send(new DeleteCommand({ TableName: table, Key: key(id), ReturnValues: 'ALL_OLD' }))).Attributes
213
+ return !!(
214
+ await client.send(
215
+ new DeleteCommand({ TableName: table, Key: key(id), ReturnValues: 'ALL_OLD' }),
216
+ )
217
+ ).Attributes
105
218
  },
106
219
  }
107
220
  }
108
221
 
109
222
  /** `db.adapter@1` over a DynamoDB table. */
110
- export const adapter: DbAdapter<DynamoTarget> = { name: 'dynamodb', source: (t, spec) => dynamoSource(t, spec as DynamoSpec) }
223
+ export const adapter: DbAdapter<DynamoTarget> = {
224
+ name: 'dynamodb',
225
+ source: (t, spec) => dynamoSource(t, spec as DynamoSpec),
226
+ }