@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 +9 -2
- package/package.json +13 -4
- package/plugins/dynamodb.mjs +10 -2
- package/plugins/firestore.mjs +10 -2
- package/plugins/kysely.mjs +10 -2
- package/plugins/mongo.mjs +10 -2
- package/plugins/native.mjs +16 -4
- package/plugins/prisma-client.mjs +29 -6
- package/plugins/prisma.mjs +10 -2
- package/plugins/sql.mjs +10 -2
- package/src/driver.ts +11 -4
- package/src/dynamodb.ts +165 -49
- package/src/firestore.ts +89 -31
- package/src/kysely.ts +108 -32
- package/src/mongo.ts +125 -30
- package/src/native/auth.ts +37 -9
- package/src/native/index.ts +36 -23
- package/src/native/mysql.ts +22 -5
- package/src/native/postgres.ts +28 -10
- package/src/native/sqlite.ts +20 -6
- package/src/prisma-client.ts +53 -24
- package/src/prisma.ts +102 -28
- package/src/sql.ts +141 -43
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @weft-js/db
|
|
2
2
|
|
|
3
|
-
> **Early release (0.1.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/plugins/dynamodb.mjs
CHANGED
|
@@ -9,8 +9,16 @@ export const dynamoSource = definePlugin({
|
|
|
9
9
|
weft: '*',
|
|
10
10
|
permissions: { net: ['driver:aws-sdk'] },
|
|
11
11
|
contribute: () => ({
|
|
12
|
-
provides: [
|
|
13
|
-
|
|
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
|
|
package/plugins/firestore.mjs
CHANGED
|
@@ -9,8 +9,16 @@ export const firestoreSource = definePlugin({
|
|
|
9
9
|
weft: '*',
|
|
10
10
|
permissions: { net: ['driver:firebase-admin'] },
|
|
11
11
|
contribute: () => ({
|
|
12
|
-
provides: [
|
|
13
|
-
|
|
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
|
|
package/plugins/kysely.mjs
CHANGED
|
@@ -9,8 +9,16 @@ export const kyselySource = definePlugin({
|
|
|
9
9
|
weft: '*',
|
|
10
10
|
permissions: { net: ['driver:kysely'] },
|
|
11
11
|
contribute: () => ({
|
|
12
|
-
provides: [
|
|
13
|
-
|
|
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: [
|
|
13
|
-
|
|
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
|
|
package/plugins/native.mjs
CHANGED
|
@@ -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(
|
|
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: {
|
|
27
|
-
|
|
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(
|
|
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)
|
|
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)))
|
|
26
|
-
|
|
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: {
|
|
41
|
-
|
|
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
|
|
package/plugins/prisma.mjs
CHANGED
|
@@ -9,8 +9,16 @@ export const prismaSource = definePlugin({
|
|
|
9
9
|
weft: '*',
|
|
10
10
|
permissions: { net: ['driver:prisma'] },
|
|
11
11
|
contribute: () => ({
|
|
12
|
-
provides: [
|
|
13
|
-
|
|
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: [
|
|
13
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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))
|
|
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 {
|
|
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 } =
|
|
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 {
|
|
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 {
|
|
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>>(
|
|
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',
|
|
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(
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 = {
|
|
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 },
|
|
94
|
-
|
|
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(
|
|
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 !!(
|
|
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> = {
|
|
223
|
+
export const adapter: DbAdapter<DynamoTarget> = {
|
|
224
|
+
name: 'dynamodb',
|
|
225
|
+
source: (t, spec) => dynamoSource(t, spec as DynamoSpec),
|
|
226
|
+
}
|