@weft-js/db 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +30 -2
- package/package.json +84 -4
- package/plugins/dynamodb.d.mts +6 -0
- package/plugins/dynamodb.mjs +17 -0
- package/plugins/firestore.d.mts +6 -0
- package/plugins/firestore.mjs +17 -0
- package/plugins/kysely.d.mts +6 -0
- package/plugins/kysely.mjs +17 -0
- package/plugins/mongo.d.mts +6 -0
- package/plugins/mongo.mjs +17 -0
- package/plugins/native.d.mts +6 -0
- package/plugins/native.mjs +31 -0
- package/plugins/prisma-client.d.mts +6 -0
- package/plugins/prisma-client.mjs +45 -0
- package/plugins/prisma.d.mts +6 -0
- package/plugins/prisma.mjs +17 -0
- package/plugins/sql.d.mts +6 -0
- package/plugins/sql.mjs +17 -0
- package/src/driver.ts +20 -0
- package/src/dynamodb.ts +110 -0
- package/src/firestore.ts +99 -0
- package/src/kysely.ts +86 -0
- package/src/mongo.ts +182 -0
- package/src/native/auth.ts +45 -0
- package/src/native/bun-sqlite.d.ts +9 -0
- package/src/native/index.ts +71 -0
- package/src/native/mysql.ts +57 -0
- package/src/native/postgres.ts +26 -0
- package/src/native/sqlite.ts +74 -0
- package/src/prisma-client.ts +51 -0
- package/src/prisma.ts +62 -0
- package/src/sql.ts +118 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ravonus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weft-js/db
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Early release (0.1.1).** APIs will change between minor versions.
|
|
4
|
+
|
|
5
|
+
Database layers and list sources for [`@weft-js/start`](../start). Each driver is an optional peer dependency, and each
|
|
6
|
+
subpath loads only its own.
|
|
7
|
+
|
|
8
|
+
You rarely install this by hand. `npm create @weft-js` and `weft add db` pick the layer and write the files:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm create @weft-js my-app -- --db postgres --layer native
|
|
12
|
+
npx weft add db sqlite --layer prisma # in an existing app
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`--db` takes `sqlite`, `postgres`, `mysql` or `none`. `--layer` takes `drizzle` (the default, which needs nothing from
|
|
16
|
+
this package), `prisma` or `native`.
|
|
17
|
+
|
|
18
|
+
## What's in it
|
|
19
|
+
|
|
20
|
+
| Import | What it is |
|
|
21
|
+
|---|---|
|
|
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
|
+
| `@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 |
|
|
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
|
+
|
|
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.
|
|
28
|
+
|
|
29
|
+
## Docs
|
|
30
|
+
|
|
31
|
+
`weft docs start` covers the database: `ctx.db`, migrations with `weft db generate`, and change feeds.
|
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/db",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./sql": "./src/sql.ts",
|
|
9
|
+
"./sql/plugin": "./plugins/sql.mjs",
|
|
10
|
+
"./kysely": "./src/kysely.ts",
|
|
11
|
+
"./kysely/plugin": "./plugins/kysely.mjs",
|
|
12
|
+
"./prisma": "./src/prisma.ts",
|
|
13
|
+
"./prisma/plugin": "./plugins/prisma.mjs",
|
|
14
|
+
"./mongo": "./src/mongo.ts",
|
|
15
|
+
"./mongo/plugin": "./plugins/mongo.mjs",
|
|
16
|
+
"./dynamodb": "./src/dynamodb.ts",
|
|
17
|
+
"./dynamodb/plugin": "./plugins/dynamodb.mjs",
|
|
18
|
+
"./firestore": "./src/firestore.ts",
|
|
19
|
+
"./firestore/plugin": "./plugins/firestore.mjs",
|
|
20
|
+
"./plugin-native": "./src/native/index.ts",
|
|
21
|
+
"./plugin-native/sqlite": "./src/native/sqlite.ts",
|
|
22
|
+
"./plugin-native/postgres": "./src/native/postgres.ts",
|
|
23
|
+
"./plugin-native/mysql": "./src/native/mysql.ts",
|
|
24
|
+
"./plugin-native/auth": "./src/native/auth.ts",
|
|
25
|
+
"./plugin-native/plugin": "./plugins/native.mjs",
|
|
26
|
+
"./plugin-prisma": "./src/prisma-client.ts",
|
|
27
|
+
"./plugin-prisma/plugin": "./plugins/prisma-client.mjs"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
31
|
+
"@aws-sdk/lib-dynamodb": "^3.0.0",
|
|
32
|
+
"@prisma/client": ">=6",
|
|
33
|
+
"@weft-js/start": "^0.1.1",
|
|
34
|
+
"better-auth": "^1.7.0",
|
|
35
|
+
"firebase-admin": ">=12",
|
|
36
|
+
"kysely": "^0.29.0",
|
|
37
|
+
"mongodb": ">=6",
|
|
38
|
+
"mysql2": "^3.24.0",
|
|
39
|
+
"postgres": ">=3",
|
|
40
|
+
"@weft-js/core": "^0.1.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"@aws-sdk/client-dynamodb": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"@aws-sdk/lib-dynamodb": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@prisma/client": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"better-auth": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"firebase-admin": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"kysely": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"mongodb": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"mysql2": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"postgres": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public"
|
|
73
|
+
},
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=26"
|
|
76
|
+
},
|
|
77
|
+
"keywords": [
|
|
78
|
+
"weft"
|
|
79
|
+
],
|
|
80
|
+
"files": [
|
|
81
|
+
"src",
|
|
82
|
+
"plugins",
|
|
83
|
+
"README.md",
|
|
84
|
+
"LICENSE"
|
|
85
|
+
]
|
|
86
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** Binds the DynamoDB list source to `db.adapter@1`. */
|
|
4
|
+
declare const plugin: (options?: Record<string, unknown>) => PluginInstance<unknown>
|
|
5
|
+
/** Installed under `db.adapter@1` by `weft plugins sync`. */
|
|
6
|
+
export default plugin
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// dynamoSource() for weft.config.ts: the `db.adapter` provider under the key `dynamodb`, so the lock records it and `weft check` names it.
|
|
2
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
3
|
+
|
|
4
|
+
const slot = defineSlot({ id: 'db.adapter', version: 1, cardinality: 'keyed', sides: ['server'] })
|
|
5
|
+
|
|
6
|
+
export const dynamoSource = definePlugin({
|
|
7
|
+
name: '@weft-js/db/dynamodb',
|
|
8
|
+
version: '0.0.0',
|
|
9
|
+
weft: '*',
|
|
10
|
+
permissions: { net: ['driver:aws-sdk'] },
|
|
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.' },
|
|
14
|
+
}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default dynamoSource
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** Binds the Firestore list source to `db.adapter@1`. */
|
|
4
|
+
declare const plugin: (options?: Record<string, unknown>) => PluginInstance<unknown>
|
|
5
|
+
/** Installed under `db.adapter@1` by `weft plugins sync`. */
|
|
6
|
+
export default plugin
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// firestoreSource() for weft.config.ts: the `db.adapter` provider under the key `firestore`, so the lock records it and `weft check` names it.
|
|
2
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
3
|
+
|
|
4
|
+
const slot = defineSlot({ id: 'db.adapter', version: 1, cardinality: 'keyed', sides: ['server'] })
|
|
5
|
+
|
|
6
|
+
export const firestoreSource = definePlugin({
|
|
7
|
+
name: '@weft-js/db/firestore',
|
|
8
|
+
version: '0.0.0',
|
|
9
|
+
weft: '*',
|
|
10
|
+
permissions: { net: ['driver:firebase-admin'] },
|
|
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.' },
|
|
14
|
+
}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default firestoreSource
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** Binds the Kysely list source to `db.adapter@1`. */
|
|
4
|
+
declare const plugin: (options?: Record<string, unknown>) => PluginInstance<unknown>
|
|
5
|
+
/** Installed under `db.adapter@1` by `weft plugins sync`. */
|
|
6
|
+
export default plugin
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// kyselySource() for weft.config.ts: the `db.adapter` provider under the key `kysely`, so the lock records it and `weft check` names it.
|
|
2
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
3
|
+
|
|
4
|
+
const slot = defineSlot({ id: 'db.adapter', version: 1, cardinality: 'keyed', sides: ['server'] })
|
|
5
|
+
|
|
6
|
+
export const kyselySource = definePlugin({
|
|
7
|
+
name: '@weft-js/db/kysely',
|
|
8
|
+
version: '0.0.0',
|
|
9
|
+
weft: '*',
|
|
10
|
+
permissions: { net: ['driver:kysely'] },
|
|
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.' },
|
|
14
|
+
}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default kyselySource
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** Binds the MongoDB list source to `db.adapter@1`. */
|
|
4
|
+
declare const plugin: (options?: Record<string, unknown>) => PluginInstance<unknown>
|
|
5
|
+
/** Installed under `db.adapter@1` by `weft plugins sync`. */
|
|
6
|
+
export default plugin
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// mongoSource() for weft.config.ts: the `db.adapter` provider under the key `mongo`, so the lock records it and `weft check` names it.
|
|
2
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
3
|
+
|
|
4
|
+
const slot = defineSlot({ id: 'db.adapter', version: 1, cardinality: 'keyed', sides: ['server'] })
|
|
5
|
+
|
|
6
|
+
export const mongoSource = definePlugin({
|
|
7
|
+
name: '@weft-js/db/mongo',
|
|
8
|
+
version: '0.0.0',
|
|
9
|
+
weft: '*',
|
|
10
|
+
permissions: { net: ['driver:mongodb'] },
|
|
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.' },
|
|
14
|
+
}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default mongoSource
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// native() for weft.config.ts (research 34 §4): the no-ORM data layer. It holds what the CLI needs: where migrations
|
|
2
|
+
// live and how `weft db generate` starts one. The runtime is `@weft-js/db/plugin-native/<engine>`.
|
|
3
|
+
import { mkdirSync, readdirSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { definePlugin } from '@weft-js/core/plugin'
|
|
6
|
+
|
|
7
|
+
const DIR = 'migrations'
|
|
8
|
+
|
|
9
|
+
/** A new, empty, numbered migration for you to write: with no schema there is nothing to diff. */
|
|
10
|
+
function generate(root, name = 'change') {
|
|
11
|
+
const dir = join(root, DIR)
|
|
12
|
+
mkdirSync(dir, { recursive: true })
|
|
13
|
+
const next = readdirSync(dir).filter(f => f.endsWith('.sql')).length
|
|
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')
|
|
16
|
+
return `${DIR}/${file}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const native = definePlugin({
|
|
20
|
+
name: '@weft-js/db/plugin-native',
|
|
21
|
+
version: '0.0.0',
|
|
22
|
+
weft: '*',
|
|
23
|
+
permissions: { fs: { read: ['migrations/**'], write: ['migrations/**'] } },
|
|
24
|
+
contribute: () => ({
|
|
25
|
+
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' }],
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export default native
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// prisma() for weft.config.ts (research 34 §4): what the CLI needs from Prisma. `weft db generate` writes the client and
|
|
2
|
+
// a migration into `prisma/migrations`. The first needs no database; later ones are Prisma's own diff, which does.
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { definePlugin } from '@weft-js/core/plugin'
|
|
8
|
+
|
|
9
|
+
const DIR = 'prisma/migrations'
|
|
10
|
+
|
|
11
|
+
function run(root, args) {
|
|
12
|
+
let bin
|
|
13
|
+
try {
|
|
14
|
+
bin = join(dirname(createRequire(join(root, 'package.json')).resolve('prisma/package.json')), 'build/index.js')
|
|
15
|
+
} catch {
|
|
16
|
+
throw Object.assign(new Error('prisma is not installed in this project'), { code: 'E_USAGE' })
|
|
17
|
+
}
|
|
18
|
+
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' })
|
|
20
|
+
return r.stdout
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function generate(root, name = 'change') {
|
|
24
|
+
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'])
|
|
27
|
+
const dir = join(root, DIR, '00000000000000_init')
|
|
28
|
+
mkdirSync(dir, { recursive: true })
|
|
29
|
+
writeFileSync(join(dir, 'migration.sql'), sql)
|
|
30
|
+
return `${DIR}/00000000000000_init/migration.sql`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const prisma = definePlugin({
|
|
34
|
+
name: '@weft-js/db/plugin-prisma',
|
|
35
|
+
version: '0.0.0',
|
|
36
|
+
weft: '*',
|
|
37
|
+
permissions: { fs: { read: ['prisma/**'], write: ['prisma/migrations/**'] }, spawn: ['node'] },
|
|
38
|
+
contribute: () => ({
|
|
39
|
+
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' }],
|
|
42
|
+
}),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export default prisma
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** Binds the Prisma list source to `db.adapter@1`. */
|
|
4
|
+
declare const plugin: (options?: Record<string, unknown>) => PluginInstance<unknown>
|
|
5
|
+
/** Installed under `db.adapter@1` by `weft plugins sync`. */
|
|
6
|
+
export default plugin
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// prismaSource() for weft.config.ts: the `db.adapter` provider under the key `prisma`, so the lock records it and `weft check` names it.
|
|
2
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
3
|
+
|
|
4
|
+
const slot = defineSlot({ id: 'db.adapter', version: 1, cardinality: 'keyed', sides: ['server'] })
|
|
5
|
+
|
|
6
|
+
export const prismaSource = definePlugin({
|
|
7
|
+
name: '@weft-js/db/prisma',
|
|
8
|
+
version: '0.0.0',
|
|
9
|
+
weft: '*',
|
|
10
|
+
permissions: { net: ['driver:prisma'] },
|
|
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.' },
|
|
14
|
+
}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default prismaSource
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** Binds the bound-SQL list source to `db.adapter@1`. */
|
|
4
|
+
declare const plugin: (options?: Record<string, unknown>) => PluginInstance<unknown>
|
|
5
|
+
/** Installed under `db.adapter@1` by `weft plugins sync`. */
|
|
6
|
+
export default plugin
|
package/plugins/sql.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// sqlSource() for weft.config.ts: the `db.adapter` provider under the key `sql`, so the lock records it and `weft check` names it.
|
|
2
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
3
|
+
|
|
4
|
+
const slot = defineSlot({ id: 'db.adapter', version: 1, cardinality: 'keyed', sides: ['server'] })
|
|
5
|
+
|
|
6
|
+
export const sqlSource = definePlugin({
|
|
7
|
+
name: '@weft-js/db/sql',
|
|
8
|
+
version: '0.0.0',
|
|
9
|
+
weft: '*',
|
|
10
|
+
permissions: { net: ['driver:query'] },
|
|
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.' },
|
|
14
|
+
}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default sqlSource
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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.
|
|
3
|
+
|
|
4
|
+
const MISSING = new Set(['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'])
|
|
5
|
+
|
|
6
|
+
/** True when this failure is `pkg` itself not being there, not something inside it failing to load. */
|
|
7
|
+
const isMissing = (error: unknown, pkg: string) => {
|
|
8
|
+
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)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `await driver('@weft-js/db/kysely', 'kysely', () => import('kysely'))`. The import stays literal so a bundler still sees it. */
|
|
13
|
+
export async function driver<T>(subpath: string, pkg: string, load: () => Promise<T>): Promise<T> {
|
|
14
|
+
try {
|
|
15
|
+
return await load()
|
|
16
|
+
} 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 })
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/dynamodb.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A list source over a DynamoDB table, through a `DynamoDBDocumentClient`. DynamoDB has no offset and sorts only inside a
|
|
2
|
+
// partition, so this is cursor mode only: a `Scan` by default, and a `Query` when `partition` and `sortKey` name an index.
|
|
3
|
+
|
|
4
|
+
import { pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
|
|
5
|
+
import { StartError } from '@weft-js/start/data'
|
|
6
|
+
import { driver } from './driver'
|
|
7
|
+
|
|
8
|
+
const { DeleteCommand, GetCommand, PutCommand, QueryCommand, ScanCommand, UpdateCommand } = await driver('@weft-js/db/dynamodb', '@aws-sdk/lib-dynamodb', () => import('@aws-sdk/lib-dynamodb'))
|
|
9
|
+
|
|
10
|
+
type Item = Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
/** 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 }
|
|
14
|
+
|
|
15
|
+
/** 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 }
|
|
17
|
+
|
|
18
|
+
/** A source's allowlist, and the index that lets a list sort. */
|
|
19
|
+
export interface DynamoSpec extends SourceSpec {
|
|
20
|
+
/** With `sortKey`: list one partition through `index`, sorted. */
|
|
21
|
+
partition?: { column: string; value: unknown }
|
|
22
|
+
/** The public name of the index's sort key: the one thing that can be sorted on. */
|
|
23
|
+
sortKey?: string
|
|
24
|
+
index?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CONDITION = 'ConditionalCheckFailedException'
|
|
28
|
+
const PAGE = 1 << 12
|
|
29
|
+
|
|
30
|
+
/** A `ListSource` over one table, in cursor mode. */
|
|
31
|
+
export function dynamoSource<Row = Record<string, unknown>>({ client, table }: DynamoTarget, spec: DynamoSpec): ListSource<Row> {
|
|
32
|
+
const reading = rowOf<Row>(spec)
|
|
33
|
+
const idField = spec.id ?? 'id', idColumn = storedId(spec)
|
|
34
|
+
const key = (id: unknown) => ({ [idColumn]: id })
|
|
35
|
+
const sortColumn = spec.sortKey && spec.columns[spec.sortKey]
|
|
36
|
+
const stored = Object.values(spec.columns)
|
|
37
|
+
const projection = stored.map((_, i) => `#c${i}`).join(', ')
|
|
38
|
+
const names = Object.fromEntries(stored.map((c, i) => [`#c${i}`, c]))
|
|
39
|
+
const byId = async (id: unknown) => {
|
|
40
|
+
const r = await client.send(new GetCommand({ TableName: table, Key: key(id), ConsistentRead: true }))
|
|
41
|
+
return r.Item ? reading(r.Item) : null
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
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 ')})`
|
|
56
|
+
})
|
|
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
|
+
},
|
|
83
|
+
get: byId,
|
|
84
|
+
async create(data) {
|
|
85
|
+
const Item = { ...written(spec, data, true), ...(spec.partition ? { [spec.partition.column]: spec.partition.value } : {}) }
|
|
86
|
+
await client.send(new PutCommand({ TableName: table, Item }))
|
|
87
|
+
return reading(Item)
|
|
88
|
+
},
|
|
89
|
+
async update(id, patch) {
|
|
90
|
+
const row = written(spec, patch)
|
|
91
|
+
const keys = Object.keys(row)
|
|
92
|
+
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] })
|
|
95
|
+
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' }))
|
|
97
|
+
return reading(r.Attributes!)
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if ((e as { name?: string } | null)?.name === CONDITION) return null
|
|
100
|
+
throw e
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async remove(id) {
|
|
104
|
+
return !!(await client.send(new DeleteCommand({ TableName: table, Key: key(id), ReturnValues: 'ALL_OLD' }))).Attributes
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `db.adapter@1` over a DynamoDB table. */
|
|
110
|
+
export const adapter: DbAdapter<DynamoTarget> = { name: 'dynamodb', source: (t, spec) => dynamoSource(t, spec as DynamoSpec) }
|
package/src/firestore.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// A list source over a Firestore collection (the Admin SDK's `CollectionReference`, or anything shaped like it). The
|
|
2
|
+
// document id is the row's id. Firestore can't search inside a string, and a range is one field at a time, so `search`
|
|
3
|
+
// is a prefix on the first search column, and can't be combined with another sort.
|
|
4
|
+
|
|
5
|
+
import { pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
|
|
6
|
+
import { StartError } from '@weft-js/start/data'
|
|
7
|
+
|
|
8
|
+
const NAME = '__name__', HIGH = '', IN_MAX = 30
|
|
9
|
+
|
|
10
|
+
/** One document, read. */
|
|
11
|
+
export interface FirestoreDoc { id: string; exists: boolean; data(): object | undefined }
|
|
12
|
+
|
|
13
|
+
/** Where one document lives. */
|
|
14
|
+
export interface FirestoreRef {
|
|
15
|
+
get(): Promise<FirestoreDoc>
|
|
16
|
+
create(data: object): Promise<unknown>
|
|
17
|
+
update(data: object): Promise<unknown>
|
|
18
|
+
delete(): Promise<unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The Admin SDK's query, as far as a list builds one. */
|
|
22
|
+
export interface FirestoreQuery {
|
|
23
|
+
where(field: string, op: never, value: unknown): FirestoreQuery
|
|
24
|
+
orderBy(field: string, dir: 'asc' | 'desc'): FirestoreQuery
|
|
25
|
+
startAfter(...values: unknown[]): FirestoreQuery
|
|
26
|
+
offset(n: number): FirestoreQuery
|
|
27
|
+
limit(n: number): FirestoreQuery
|
|
28
|
+
count(): { get(): Promise<{ data(): { count: number } }> }
|
|
29
|
+
get(): Promise<{ docs: FirestoreDoc[] }>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The Admin SDK's `CollectionReference`, as far as a source calls it. */
|
|
33
|
+
export interface FirestoreCollection {
|
|
34
|
+
select(...fields: string[]): FirestoreQuery
|
|
35
|
+
doc(id?: string): FirestoreRef
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A `ListSource` over one collection, keyed by document id. */
|
|
39
|
+
export function firestoreSource<Row = Record<string, unknown>>(collection: FirestoreCollection, spec: SourceSpec): ListSource<Row> {
|
|
40
|
+
const reading = rowOf<Row>(spec)
|
|
41
|
+
const idField = spec.id ?? 'id', idColumn = storedId(spec)
|
|
42
|
+
const stored = Object.values(spec.columns).filter(c => c !== idColumn)
|
|
43
|
+
const row = (doc: FirestoreDoc) => reading({ [idColumn]: doc.id, ...doc.data() })
|
|
44
|
+
const at = (id: unknown) => collection.doc(String(id))
|
|
45
|
+
return {
|
|
46
|
+
limits: { search: 'prefix' },
|
|
47
|
+
async list(p: ListParams) {
|
|
48
|
+
const q = plan(spec, p)
|
|
49
|
+
let b = collection.select(...stored)
|
|
50
|
+
for (const w of q.where) {
|
|
51
|
+
if (w.values.length > IN_MAX) throw new StartError('Q002', `Firestore takes ${IN_MAX} values in a filter`, 'ask for fewer')
|
|
52
|
+
b = w.values.length === 1 ? b.where(w.column, '==' as never, w.values[0]) : b.where(w.column, 'in' as never, w.values)
|
|
53
|
+
}
|
|
54
|
+
let first = q.sort?.column === idColumn ? null : q.sort?.column ?? null
|
|
55
|
+
const dir = q.sort?.dir ?? 'asc'
|
|
56
|
+
if (q.search) {
|
|
57
|
+
const field = q.search.columns[0]!
|
|
58
|
+
if (first && first !== field) throw new StartError('Q002', 'Firestore can\'t search one field and sort by another', 'drop one')
|
|
59
|
+
first = field
|
|
60
|
+
b = b.where(field, '>=' as never, q.search.text).where(field, '<=' as never, q.search.text + HIGH)
|
|
61
|
+
}
|
|
62
|
+
const counted = b
|
|
63
|
+
if (first) b = b.orderBy(first, dir)
|
|
64
|
+
b = b.orderBy(NAME, dir)
|
|
65
|
+
if (p.after) b = first ? b.startAfter(p.after[0], p.after[1]) : b.startAfter(p.after[1])
|
|
66
|
+
const cursor = p.mode === 'cursor'
|
|
67
|
+
if (p.offset) b = b.offset(p.offset)
|
|
68
|
+
const rows = (await b.limit(cursor ? p.limit + 1 : p.limit).get()).docs.map(row)
|
|
69
|
+
const total = cursor ? undefined : (await counted.count().get()).data().count
|
|
70
|
+
return pageOf(rows, p, { sort: q.sort?.field ?? (first ? Object.keys(spec.columns).find(k => spec.columns[k] === first) : undefined), id: idField }, total)
|
|
71
|
+
},
|
|
72
|
+
async get(id) {
|
|
73
|
+
const doc = await at(id).get()
|
|
74
|
+
return doc.exists ? row(doc) : null
|
|
75
|
+
},
|
|
76
|
+
async create(data) {
|
|
77
|
+
const { [idColumn]: id, ...rest } = written(spec, data, true)
|
|
78
|
+
const ref = id === undefined ? collection.doc() : at(id)
|
|
79
|
+
await ref.create(rest)
|
|
80
|
+
return row(await ref.get())
|
|
81
|
+
},
|
|
82
|
+
async update(id, patch) {
|
|
83
|
+
const ref = at(id)
|
|
84
|
+
const body = written(spec, patch)
|
|
85
|
+
if (!(await ref.get()).exists) return null
|
|
86
|
+
if (Object.keys(body).length) await ref.update(body)
|
|
87
|
+
return row(await ref.get())
|
|
88
|
+
},
|
|
89
|
+
async remove(id) {
|
|
90
|
+
const ref = at(id)
|
|
91
|
+
if (!(await ref.get()).exists) return false
|
|
92
|
+
await ref.delete()
|
|
93
|
+
return true
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `db.adapter@1` over a Firestore collection. */
|
|
99
|
+
export const adapter: DbAdapter<FirestoreCollection> = { name: 'firestore', source: (t, spec) => firestoreSource(t, spec) }
|