@jcbuisson/express-x-plugins 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/package.json +52 -0
- package/src/electric-client-plugin.mjs +141 -0
- package/src/electric-server-plugin.mjs +250 -0
- package/src/reload-client-plugin.mjs +45 -0
- package/src/reload-server-plugin.mjs +95 -0
- package/test/electric-client-plugin.test.mjs +61 -0
- package/test/electric-server-plugin.test.mjs +104 -0
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# express-x-electric
|
|
2
|
+
|
|
3
|
+
The smallest useful ElectricSQL integration for Express-X. Express-X handles
|
|
4
|
+
authorized PostgreSQL mutations; Electric's Shape API streams those changes to
|
|
5
|
+
clients. It is analogous to `express-x-drizzle`, but deliberately has no
|
|
6
|
+
metadata table and no custom `sync.go`: Electric is the sync engine.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @jcbuisson/express-x-electric pg
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This server-only installation does not install the browser Electric client or
|
|
15
|
+
RxJS.
|
|
16
|
+
|
|
17
|
+
## Server
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { Pool } from 'pg'
|
|
21
|
+
import { expressX } from '@jcbuisson/express-x'
|
|
22
|
+
import { electricOfflinePlugin } from '@jcbuisson/express-x-electric'
|
|
23
|
+
|
|
24
|
+
const app = expressX()
|
|
25
|
+
const db = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
26
|
+
|
|
27
|
+
app.configure(electricOfflinePlugin, db, [
|
|
28
|
+
'todos',
|
|
29
|
+
{ name: 'projects', table: 'projects', primaryKey: 'uid' },
|
|
30
|
+
], {
|
|
31
|
+
electricUrl: process.env.ELECTRIC_URL, // ElectricSQL sync service, e.g. http://localhost:3000/v1/shape
|
|
32
|
+
sourceId: process.env.ELECTRIC_SOURCE_ID,
|
|
33
|
+
sourceSecret: process.env.ELECTRIC_SOURCE_SECRET,
|
|
34
|
+
authorize: async (context, { modelName, action }) => {
|
|
35
|
+
// `context.transport` is "http" for Shapes and "ws" for Express-X calls.
|
|
36
|
+
return Boolean(context.request?.user || context.socket?.data?.user)
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This registers one Express-X service per model with the familiar API:
|
|
42
|
+
|
|
43
|
+
- `createWithMeta(uid, data, createdAt)`
|
|
44
|
+
- `updateWithMeta(uid, data, updatedAt)`
|
|
45
|
+
- `deleteWithMeta(uid, deletedAt)`
|
|
46
|
+
|
|
47
|
+
Synchronized reads are provided client-side by `getObservable(where)` below;
|
|
48
|
+
one-shot server reads would bypass Electric and are intentionally omitted.
|
|
49
|
+
|
|
50
|
+
Mutation results remain `[value, meta]` tuples for compatibility. `meta.txid`
|
|
51
|
+
contains `pg_current_xact_id()` and can be passed to an Electric-aware client to
|
|
52
|
+
wait for the matching transaction in its Shape stream.
|
|
53
|
+
|
|
54
|
+
## Client Shape
|
|
55
|
+
|
|
56
|
+
Install the optional client dependencies in the browser application:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm install @jcbuisson/express-x-electric @electric-sql/client rxjs
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Configure the client plugin and use the same `getObservable(where)` style as
|
|
63
|
+
`express-x-client`'s offline model:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import { electricClientPlugin } from '@jcbuisson/express-x-electric/client'
|
|
67
|
+
|
|
68
|
+
app.configure(electricClientPlugin, {
|
|
69
|
+
shapePath: '/electric/v1/shape',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const todo = app.createElectricModel('todos')
|
|
73
|
+
const subscription = todo.getObservable({ completed: false }).subscribe(rows => {
|
|
74
|
+
console.log(rows)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Mutations use the matching Express-X service and are reflected by Electric.
|
|
78
|
+
await todo.create({ title: 'Learn Shapes', completed: false })
|
|
79
|
+
await todo.update(uid, { completed: true })
|
|
80
|
+
await todo.remove(uid)
|
|
81
|
+
|
|
82
|
+
subscription.unsubscribe()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Object filters use parameterized Electric Shape predicates. Exact values,
|
|
86
|
+
`null`, and `gt`/`gte`/`lt`/`lte` ranges are supported.
|
|
87
|
+
|
|
88
|
+
All Electric cursor parameters are forwarded. The client cannot override the
|
|
89
|
+
configured table, and Electric source credentials stay server-side.
|
|
90
|
+
|
|
91
|
+
## Model configuration
|
|
92
|
+
|
|
93
|
+
Models may be strings (table, service name, and default `uid` key) or objects:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
{ name: 'todo', table: 'todos', primaryKey: 'id' }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Names are restricted to simple PostgreSQL identifiers. Values are always sent
|
|
100
|
+
as query parameters; range filters support `gt`, `gte`, `lt`, and `lte`.
|
|
101
|
+
|
|
102
|
+
Requires Node 18+ for the built-in Fetch API. The PostgreSQL client only needs a
|
|
103
|
+
`query(sql, values)` method; a `pg.Pool` is recommended so each mutation and its
|
|
104
|
+
transaction ID are captured in the same transaction.
|
|
105
|
+
# express-x-electric
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jcbuisson/express-x-plugins",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Plugins for express-x",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/electric-server-plugin.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./server/electric": "./src/electric-server-plugin.mjs",
|
|
9
|
+
"./client/electric": "./src/electric-client-plugin.mjs",
|
|
10
|
+
"./server/reload": "./src/reload-server-plugin.mjs",
|
|
11
|
+
"./client/reload": "./src/reload-client-plugin.mjs"
|
|
12
|
+
},
|
|
13
|
+
"private": false,
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.0.0",
|
|
19
|
+
"npm": ">=8.0.0"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+ssh://git@github.com/jcbuisson/express-x-plugins.git"
|
|
24
|
+
},
|
|
25
|
+
"author": "Jean-Christophe Buisson <buisson@enseeiht.fr> (jcbuisson.dev)",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"express-x",
|
|
29
|
+
"electric-sql",
|
|
30
|
+
"sync",
|
|
31
|
+
"local-first"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@electric-sql/client": "^1.5.24",
|
|
38
|
+
"rxjs": "^7.8.2"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@electric-sql/client": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"rxjs": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@electric-sql/client": "^1.5.24",
|
|
50
|
+
"rxjs": "^7.8.2"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Shape, ShapeStream } from '@electric-sql/client'
|
|
2
|
+
import { Observable } from 'rxjs'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Add Electric-backed reactive models to an Express-X client.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* electricClientPlugin(app)
|
|
10
|
+
* const todo = app.createElectricModel('todos')
|
|
11
|
+
* todo.getObservable({ completed: false }).subscribe(...)
|
|
12
|
+
*/
|
|
13
|
+
export function electricClientPlugin(app, options = {}) {
|
|
14
|
+
const shapePath = options.shapePath ?? '/electric/v1/shape'
|
|
15
|
+
const ShapeStreamClass = options.ShapeStream ?? ShapeStream
|
|
16
|
+
const ShapeClass = options.Shape ?? Shape
|
|
17
|
+
const ObservableClass = options.Observable ?? Observable
|
|
18
|
+
|
|
19
|
+
function createElectricModel(modelName, modelOptions = {}) {
|
|
20
|
+
quoteIdentifier(modelName)
|
|
21
|
+
const service = app.service(modelName)
|
|
22
|
+
const url = modelOptions.url ?? modelPath(shapePath, modelName)
|
|
23
|
+
const streamOptions = modelOptions.streamOptions ?? {}
|
|
24
|
+
|
|
25
|
+
function getObservable(where = {}) {
|
|
26
|
+
// Validate eagerly, as getObservable in express-x-client does.
|
|
27
|
+
const filterParams = whereToElectricParams(where)
|
|
28
|
+
return new ObservableClass(subscriber => {
|
|
29
|
+
const stream = new ShapeStreamClass({
|
|
30
|
+
...streamOptions,
|
|
31
|
+
url,
|
|
32
|
+
params: { ...streamOptions.params, ...filterParams },
|
|
33
|
+
})
|
|
34
|
+
const shape = new ShapeClass(stream)
|
|
35
|
+
let previous
|
|
36
|
+
const unsubscribe = shape.subscribe(({ rows }) => {
|
|
37
|
+
const current = [...rows]
|
|
38
|
+
const serialized = JSON.stringify(current)
|
|
39
|
+
if (serialized === previous) return
|
|
40
|
+
previous = serialized
|
|
41
|
+
subscriber.next(current)
|
|
42
|
+
})
|
|
43
|
+
// Shape exposes errors as state. ShapeStream retries transient failures;
|
|
44
|
+
// callers keep one observable subscription across reconnects.
|
|
45
|
+
return () => unsubscribe()
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function create(data) {
|
|
50
|
+
assertPlainObject(data, 'mutation data')
|
|
51
|
+
const uid = globalThis.crypto?.randomUUID?.()
|
|
52
|
+
if (!uid) throw new Error('crypto.randomUUID() is required')
|
|
53
|
+
const now = new Date().toISOString()
|
|
54
|
+
const [value] = await service.createWithMeta(uid, data, now)
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function update(uid, data) {
|
|
59
|
+
const [value] = await service.updateWithMeta(uid, data, new Date().toISOString())
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function remove(uid) {
|
|
64
|
+
const [value] = await service.deleteWithMeta(uid, new Date().toISOString())
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { getObservable, create, update, remove }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return Object.assign(app, { createElectricModel })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
////////////////////// UTILITIES //////////////////////
|
|
76
|
+
|
|
77
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
78
|
+
const RANGE_OPERATORS = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
|
|
79
|
+
|
|
80
|
+
function quoteIdentifier(value) {
|
|
81
|
+
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
|
|
82
|
+
throw new TypeError(`'${value}' must be a simple SQL identifier`)
|
|
83
|
+
}
|
|
84
|
+
return `"${value}"`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertPlainObject(value, label) {
|
|
88
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.prototype.toString.call(value) !== '[object Object]') {
|
|
89
|
+
throw new TypeError(`${label} must be a plain object`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function serializeValue(value, path) {
|
|
94
|
+
if (value instanceof Date) {
|
|
95
|
+
if (Number.isNaN(value.getTime())) throw new TypeError(`${path} contains an invalid Date`)
|
|
96
|
+
return value.toISOString()
|
|
97
|
+
}
|
|
98
|
+
if (['string', 'number', 'boolean'].includes(typeof value) && value !== undefined) {
|
|
99
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
100
|
+
throw new TypeError(`${path} contains a non-finite number`)
|
|
101
|
+
}
|
|
102
|
+
return String(value)
|
|
103
|
+
}
|
|
104
|
+
throw new TypeError(`${path} contains an unsupported value`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Convert the Express-X object filter into Electric's parameterized SQL filter. */
|
|
108
|
+
function whereToElectricParams(where = {}) {
|
|
109
|
+
assertPlainObject(where, 'where')
|
|
110
|
+
const clauses = []
|
|
111
|
+
const params = []
|
|
112
|
+
|
|
113
|
+
for (const [column, constraint] of Object.entries(where)) {
|
|
114
|
+
const quotedColumn = quoteIdentifier(column)
|
|
115
|
+
if (constraint === undefined) continue
|
|
116
|
+
if (constraint === null) {
|
|
117
|
+
clauses.push(`${quotedColumn} IS NULL`)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (constraint && typeof constraint === 'object' && !Array.isArray(constraint) && !(constraint instanceof Date)) {
|
|
121
|
+
const entries = Object.entries(constraint)
|
|
122
|
+
if (entries.length === 0 || entries.some(([operator]) => !RANGE_OPERATORS[operator])) {
|
|
123
|
+
throw new TypeError(`unsupported where constraint for '${column}'`)
|
|
124
|
+
}
|
|
125
|
+
for (const [operator, value] of entries) {
|
|
126
|
+
params.push(serializeValue(value, `where.${column}.${operator}`))
|
|
127
|
+
clauses.push(`${quotedColumn} ${RANGE_OPERATORS[operator]} $${params.length}`)
|
|
128
|
+
}
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
params.push(serializeValue(constraint, `where.${column}`))
|
|
132
|
+
clauses.push(`${quotedColumn} = $${params.length}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return clauses.length ? { where: clauses.join(' AND '), params } : {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function modelPath(shapePath, modelName) {
|
|
139
|
+
const base = shapePath.replace(/\/$/, '')
|
|
140
|
+
return `${base}/${encodeURIComponent(modelName)}`
|
|
141
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register Express-X mutation services and an Electric Shape proxy.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} app Express-X/Express application
|
|
5
|
+
* @param {object} db pg-compatible Pool or Client exposing query()
|
|
6
|
+
* @param {(string|{name:string,table?:string,primaryKey?:string})[]} models
|
|
7
|
+
* @param {object} options
|
|
8
|
+
*/
|
|
9
|
+
export function electricOfflinePlugin(app, db, models, options = {}) {
|
|
10
|
+
if (!db || typeof db.query !== 'function') throw new TypeError('db must expose query(sql, values)')
|
|
11
|
+
if (typeof options.authorize !== 'function') {
|
|
12
|
+
throw new TypeError('electricOfflinePlugin requires an authorize(context, operation) policy')
|
|
13
|
+
}
|
|
14
|
+
const configuredModels = normalizeModels(models)
|
|
15
|
+
const electricUrl = new URL(options.electricUrl ?? process.env.ELECTRIC_URL ?? 'http://localhost:3000/v1/shape')
|
|
16
|
+
const shapePath = options.shapePath ?? '/electric/v1/shape/:model'
|
|
17
|
+
const fetchImpl = options.fetch ?? globalThis.fetch
|
|
18
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('a fetch implementation is required')
|
|
19
|
+
|
|
20
|
+
async function authorize(context, modelName, action, args) {
|
|
21
|
+
const allowed = await options.authorize(context, { modelName, action, args })
|
|
22
|
+
if (!allowed) {
|
|
23
|
+
const error = new Error(`not authorized to ${action} '${modelName}'`)
|
|
24
|
+
error.code = 'forbidden'
|
|
25
|
+
throw error
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const model of configuredModels) {
|
|
30
|
+
app.createService(model.name, {
|
|
31
|
+
// findUnique: async function(where) {
|
|
32
|
+
// await authorize(this, model.name, 'findUnique', [where])
|
|
33
|
+
// const filter = buildWhere(where)
|
|
34
|
+
// const result = await db.query(`SELECT * FROM ${model.quotedTable} WHERE ${filter.sql} LIMIT 1`, filter.values)
|
|
35
|
+
// return result.rows[0] ?? null
|
|
36
|
+
// },
|
|
37
|
+
|
|
38
|
+
// findMany: async function(where, queryOptions = {}) {
|
|
39
|
+
// await authorize(this, model.name, 'findMany', [where, queryOptions])
|
|
40
|
+
// const filter = buildWhere(where)
|
|
41
|
+
// let sql = `SELECT * FROM ${model.quotedTable} WHERE ${filter.sql}`
|
|
42
|
+
// const values = [...filter.values]
|
|
43
|
+
// if (queryOptions.limit != null) {
|
|
44
|
+
// if (!Number.isInteger(queryOptions.limit) || queryOptions.limit < 1) {
|
|
45
|
+
// throw new TypeError('limit must be a positive integer')
|
|
46
|
+
// }
|
|
47
|
+
// values.push(queryOptions.limit)
|
|
48
|
+
// sql += ` LIMIT $${values.length}`
|
|
49
|
+
// }
|
|
50
|
+
// return (await db.query(sql, values)).rows
|
|
51
|
+
// },
|
|
52
|
+
|
|
53
|
+
createWithMeta: async function(uid, data, createdAt = new Date()) {
|
|
54
|
+
await authorize(this, model.name, 'createWithMeta', [uid, data, createdAt])
|
|
55
|
+
assertPlainObject(data, 'mutation data')
|
|
56
|
+
const timestamp = normalizeTimestamp(createdAt, 'created_at')
|
|
57
|
+
const safeData = { ...data, [model.primaryKey]: uid }
|
|
58
|
+
const entries = Object.entries(safeData).filter(([, value]) => value !== undefined)
|
|
59
|
+
const columns = entries.map(([column]) => quoteIdentifier(column, 'data column'))
|
|
60
|
+
const values = entries.map(([, value]) => value)
|
|
61
|
+
const parameters = values.map((_, index) => `$${index + 1}`)
|
|
62
|
+
const updateEntries = entries.filter(([column]) => column !== model.primaryKey)
|
|
63
|
+
const conflictAction = updateEntries.length
|
|
64
|
+
? 'DO UPDATE SET ' + updateEntries
|
|
65
|
+
.map(([column]) => `${quoteIdentifier(column, 'data column')} = EXCLUDED.${quoteIdentifier(column, 'data column')}`).join(', ')
|
|
66
|
+
: 'DO UPDATE SET ' + `${model.quotedPrimaryKey} = EXCLUDED.${model.quotedPrimaryKey}`
|
|
67
|
+
return withTransaction(db, async client => {
|
|
68
|
+
const result = await client.query(
|
|
69
|
+
`INSERT INTO ${model.quotedTable} (${columns.join(', ')}) VALUES (${parameters.join(', ')}) `
|
|
70
|
+
+ `ON CONFLICT (${model.quotedPrimaryKey}) ${conflictAction}`
|
|
71
|
+
+ ' RETURNING *',
|
|
72
|
+
values,
|
|
73
|
+
)
|
|
74
|
+
const txid = await transactionId(client)
|
|
75
|
+
return [result.rows[0], mutationMeta(uid, 'created_at', timestamp, txid)]
|
|
76
|
+
})
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
updateWithMeta: async function(uid, data, updatedAt = new Date()) {
|
|
80
|
+
await authorize(this, model.name, 'updateWithMeta', [uid, data, updatedAt])
|
|
81
|
+
const timestamp = normalizeTimestamp(updatedAt, 'updated_at')
|
|
82
|
+
const set = buildSet(data)
|
|
83
|
+
return withTransaction(db, async client => {
|
|
84
|
+
const result = await client.query(
|
|
85
|
+
`UPDATE ${model.quotedTable} SET ${set.sql} WHERE ${model.quotedPrimaryKey} = $${set.values.length + 1} RETURNING *`,
|
|
86
|
+
[...set.values, uid],
|
|
87
|
+
)
|
|
88
|
+
const txid = await transactionId(client)
|
|
89
|
+
return [result.rows[0], mutationMeta(uid, 'updated_at', timestamp, txid)]
|
|
90
|
+
})
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
deleteWithMeta: async function(uid, deletedAt = new Date()) {
|
|
94
|
+
await authorize(this, model.name, 'deleteWithMeta', [uid, deletedAt])
|
|
95
|
+
const timestamp = normalizeTimestamp(deletedAt, 'deleted_at')
|
|
96
|
+
return withTransaction(db, async client => {
|
|
97
|
+
const result = await client.query(
|
|
98
|
+
`DELETE FROM ${model.quotedTable} WHERE ${model.quotedPrimaryKey} = $1 RETURNING *`, [uid],
|
|
99
|
+
)
|
|
100
|
+
const txid = await transactionId(client)
|
|
101
|
+
return [result.rows[0], mutationMeta(uid, 'deleted_at', timestamp, txid)]
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// uses long-polling request waiting for changes
|
|
108
|
+
app.get(shapePath, async (request, response, next) => {
|
|
109
|
+
try {
|
|
110
|
+
const model = configuredModels.find(candidate => candidate.name === request.params.model)
|
|
111
|
+
if (!model) return response.status(404).json({ error: 'unknown model' })
|
|
112
|
+
await authorize({ app, request, response, transport: 'http' }, model.name, 'shape', [request.query])
|
|
113
|
+
const target = new URL(electricUrl)
|
|
114
|
+
for (const [key, value] of Object.entries(request.query)) {
|
|
115
|
+
if (key === 'table') continue
|
|
116
|
+
if (Array.isArray(value)) value.forEach(entry => target.searchParams.append(key, entry))
|
|
117
|
+
else if (value != null) target.searchParams.set(key, value)
|
|
118
|
+
}
|
|
119
|
+
target.searchParams.set('table', model.table)
|
|
120
|
+
if (options.sourceId) target.searchParams.set('source_id', options.sourceId)
|
|
121
|
+
if (options.sourceSecret) target.searchParams.set('secret', options.sourceSecret)
|
|
122
|
+
const upstream = await fetchImpl(target, { headers: { accept: request.get('accept') ?? '*/*' } })
|
|
123
|
+
response.status(upstream.status)
|
|
124
|
+
copyResponseHeaders(upstream, response)
|
|
125
|
+
response.send(Buffer.from(await upstream.arrayBuffer()))
|
|
126
|
+
} catch (error) {
|
|
127
|
+
next(error)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return { shapePath, models: configuredModels.map(({ name, table, primaryKey }) => ({ name, table, primaryKey })) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
136
|
+
const RANGE_OPERATORS = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
|
|
137
|
+
|
|
138
|
+
function quoteIdentifier(value, label) {
|
|
139
|
+
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
|
|
140
|
+
throw new TypeError(`${label} must be a simple SQL identifier`)
|
|
141
|
+
}
|
|
142
|
+
return `"${value}"`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeModels(models) {
|
|
146
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
147
|
+
throw new TypeError('models must be a non-empty array')
|
|
148
|
+
}
|
|
149
|
+
return models.map(model => {
|
|
150
|
+
const config = typeof model === 'string' ? { name: model } : model
|
|
151
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
152
|
+
throw new TypeError('each model must be a name or configuration object')
|
|
153
|
+
}
|
|
154
|
+
const name = config.name
|
|
155
|
+
const table = config.table ?? name
|
|
156
|
+
const primaryKey = config.primaryKey ?? 'uid'
|
|
157
|
+
quoteIdentifier(name, 'model name')
|
|
158
|
+
return {
|
|
159
|
+
name,
|
|
160
|
+
table,
|
|
161
|
+
primaryKey,
|
|
162
|
+
quotedTable: quoteIdentifier(table, `table for '${name}'`),
|
|
163
|
+
quotedPrimaryKey: quoteIdentifier(primaryKey, `primary key for '${name}'`),
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertPlainObject(value, label) {
|
|
169
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
170
|
+
|| Object.prototype.toString.call(value) !== '[object Object]') {
|
|
171
|
+
throw new TypeError(`${label} must be a plain object`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildWhere(where, startIndex = 1) {
|
|
176
|
+
assertPlainObject(where, 'where')
|
|
177
|
+
const clauses = []
|
|
178
|
+
const values = []
|
|
179
|
+
for (const [column, constraint] of Object.entries(where)) {
|
|
180
|
+
const quotedColumn = quoteIdentifier(column, 'where column')
|
|
181
|
+
if (constraint === undefined) continue
|
|
182
|
+
if (constraint === null) {
|
|
183
|
+
clauses.push(`${quotedColumn} IS NULL`)
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if (constraint && typeof constraint === 'object' && !Array.isArray(constraint)) {
|
|
187
|
+
const entries = Object.entries(constraint)
|
|
188
|
+
if (entries.length === 0 || entries.some(([operator]) => !RANGE_OPERATORS[operator])) {
|
|
189
|
+
throw new TypeError(`unsupported where constraint for '${column}'`)
|
|
190
|
+
}
|
|
191
|
+
for (const [operator, value] of entries) {
|
|
192
|
+
values.push(value)
|
|
193
|
+
clauses.push(`${quotedColumn} ${RANGE_OPERATORS[operator]} $${startIndex + values.length - 1}`)
|
|
194
|
+
}
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
values.push(constraint)
|
|
198
|
+
clauses.push(`${quotedColumn} = $${startIndex + values.length - 1}`)
|
|
199
|
+
}
|
|
200
|
+
return { sql: clauses.length ? clauses.join(' AND ') : 'TRUE', values }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildSet(data, startIndex = 1) {
|
|
204
|
+
assertPlainObject(data, 'mutation data')
|
|
205
|
+
const entries = Object.entries(data).filter(([key, value]) => key !== 'uid' && value !== undefined)
|
|
206
|
+
if (entries.length === 0) throw new TypeError('mutation data must contain at least one field')
|
|
207
|
+
return {
|
|
208
|
+
sql: entries.map(([column], index) => `${quoteIdentifier(column, 'data column')} = $${startIndex + index}`).join(', '),
|
|
209
|
+
values: entries.map(([, value]) => value),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function withTransaction(db, operation) {
|
|
214
|
+
if (typeof db?.connect !== 'function') return operation(db)
|
|
215
|
+
const client = await db.connect()
|
|
216
|
+
try {
|
|
217
|
+
await client.query('BEGIN')
|
|
218
|
+
const result = await operation(client)
|
|
219
|
+
await client.query('COMMIT')
|
|
220
|
+
return result
|
|
221
|
+
} catch (error) {
|
|
222
|
+
await client.query('ROLLBACK')
|
|
223
|
+
throw error
|
|
224
|
+
} finally {
|
|
225
|
+
client.release()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function transactionId(client) {
|
|
230
|
+
const result = await client.query('SELECT pg_current_xact_id()::text AS txid')
|
|
231
|
+
return result.rows[0]?.txid
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function mutationMeta(uid, field, timestamp, txid) {
|
|
235
|
+
return { uid, created_at: null, updated_at: null, deleted_at: null, [field]: timestamp, txid }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeTimestamp(value, field) {
|
|
239
|
+
const timestamp = new Date(value)
|
|
240
|
+
if (Number.isNaN(timestamp.getTime())) throw new TypeError(`${field} must be a valid timestamp`)
|
|
241
|
+
return timestamp.toISOString()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function copyResponseHeaders(source, target) {
|
|
245
|
+
source.headers.forEach((value, key) => {
|
|
246
|
+
if (!['content-encoding', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())) {
|
|
247
|
+
target.setHeader(key, value)
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrich `app` with listeners handling socket data transfer on page reload
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* reloadPlugin(app)
|
|
6
|
+
*/
|
|
7
|
+
export async function reloadPlugin(app) {
|
|
8
|
+
|
|
9
|
+
const cnxid = useSessionStorage('cnxid', '')
|
|
10
|
+
const cnxtoken = useSessionStorage('cnxtoken', '')
|
|
11
|
+
const handleTransferToken = token => {
|
|
12
|
+
if (typeof token === 'string') cnxtoken.value = token
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
app.addConnectListener(async (socket) => {
|
|
16
|
+
const socketId = socket.id
|
|
17
|
+
console.log('connect', socketId)
|
|
18
|
+
const prevSocketId = cnxid.value
|
|
19
|
+
const prevTransferToken = cnxtoken.value
|
|
20
|
+
socket.off('cnx-transfer-token', handleTransferToken)
|
|
21
|
+
socket.on('cnx-transfer-token', handleTransferToken)
|
|
22
|
+
cnxid.value = socketId
|
|
23
|
+
if (prevSocketId && prevTransferToken) {
|
|
24
|
+
console.log('cnx-transfer', prevSocketId, 'to', socketId)
|
|
25
|
+
let timeout
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
clearTimeout(timeout)
|
|
28
|
+
socket.off('cnx-transfer-ack', handleAck)
|
|
29
|
+
socket.off('cnx-transfer-error', handleError)
|
|
30
|
+
}
|
|
31
|
+
const handleAck = (fromSocketId, toSocketId) => {
|
|
32
|
+
console.log('ACK ACK!!!', fromSocketId, toSocketId)
|
|
33
|
+
cleanup()
|
|
34
|
+
}
|
|
35
|
+
const handleError = (fromSocketId, toSocketId) => {
|
|
36
|
+
console.log('ERR ERR!!!', fromSocketId, toSocketId)
|
|
37
|
+
cleanup()
|
|
38
|
+
}
|
|
39
|
+
socket.once('cnx-transfer-ack', handleAck)
|
|
40
|
+
socket.once('cnx-transfer-error', handleError)
|
|
41
|
+
timeout = setTimeout(cleanup, 5000)
|
|
42
|
+
socket.emit('cnx-transfer', prevSocketId, socketId, prevTransferToken)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register Express-X reload plugin
|
|
3
|
+
*
|
|
4
|
+
* @param {object} app Express-X/Express application
|
|
5
|
+
* @param {object} options
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const roomCache = new WeakMap()
|
|
9
|
+
export const dataCache = new WeakMap()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export async function reloadPlugin(app, options = {}) {
|
|
13
|
+
|
|
14
|
+
const io = app.get('io')
|
|
15
|
+
const transferTtlMs = app.get('config')?.reloadTransferTtlMs ?? 2 * 60 * 1000
|
|
16
|
+
const rooms = Object.create(null)
|
|
17
|
+
const data = Object.create(null)
|
|
18
|
+
const transferTokens = Object.create(null)
|
|
19
|
+
const transferExpiryTimers = Object.create(null)
|
|
20
|
+
roomCache.set(app, rooms)
|
|
21
|
+
dataCache.set(app, data)
|
|
22
|
+
|
|
23
|
+
app.addDisconnectingListener((socket, reason) => {
|
|
24
|
+
console.log('onSocketDisconnecting', socket.id, reason)
|
|
25
|
+
// save socket data & rooms in caches
|
|
26
|
+
const alreadySavedData = data[socket.id]
|
|
27
|
+
const alreadySavedRooms = rooms[socket.id]
|
|
28
|
+
|
|
29
|
+
// Current socket.data takes precedence over stale cached data so that any
|
|
30
|
+
// updates made between disconnections are not overwritten.
|
|
31
|
+
data[socket.id] = Object.assign({}, alreadySavedData, socket.data)
|
|
32
|
+
rooms[socket.id] = new Set(socket.rooms)
|
|
33
|
+
if (alreadySavedRooms) for (const room of alreadySavedRooms) rooms[socket.id].add(room)
|
|
34
|
+
transferTokens[socket.id] = socket.data.__cnxTransferToken
|
|
35
|
+
clearTimeout(transferExpiryTimers[socket.id])
|
|
36
|
+
transferExpiryTimers[socket.id] = setTimeout(() => {
|
|
37
|
+
delete rooms[socket.id]
|
|
38
|
+
delete data[socket.id]
|
|
39
|
+
delete transferTokens[socket.id]
|
|
40
|
+
delete transferExpiryTimers[socket.id]
|
|
41
|
+
}, transferTtlMs)
|
|
42
|
+
transferExpiryTimers[socket.id].unref?.()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
app.addConnectListener((socket) => {
|
|
46
|
+
console.log('onSocketConnect', socket.id)
|
|
47
|
+
const transferToken = randomUUID()
|
|
48
|
+
socket.data.__cnxTransferToken = transferToken
|
|
49
|
+
socket.emit('cnx-transfer-token', transferToken)
|
|
50
|
+
|
|
51
|
+
// when client ask for transfer from fromSocketId to toSocketId
|
|
52
|
+
socket.on('cnx-transfer', async (fromSocketId, toSocketId, claimedToken) => {
|
|
53
|
+
app.log('verbose', `cnx-transfer from ${fromSocketId} to ${toSocketId}`)
|
|
54
|
+
// A socket may only claim its own ID as the destination — prevent session hijacking
|
|
55
|
+
if (toSocketId !== socket.id || typeof claimedToken !== 'string'
|
|
56
|
+
|| transferTokens[fromSocketId] !== claimedToken) {
|
|
57
|
+
app.log('verbose', `cnx-transfer rejected: toSocketId ${toSocketId} !== socket.id ${socket.id}`)
|
|
58
|
+
socket.emit('cnx-transfer-error', fromSocketId, toSocketId)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
// copy connection room & data from 'fromSocketId' to 'toSocketId'
|
|
62
|
+
const toSocket = io.sockets.sockets.get(toSocketId)
|
|
63
|
+
// data & rooms of fromSocketId are taken from dataCache and roomCache, since socket no longer exists
|
|
64
|
+
const fromSocketRooms = rooms[fromSocketId]
|
|
65
|
+
if (toSocket && fromSocketRooms) {
|
|
66
|
+
// copy rooms
|
|
67
|
+
for (const room of fromSocketRooms) {
|
|
68
|
+
if (room === fromSocketId) continue // do not include room associated to socket#id
|
|
69
|
+
const allowed = typeof options.authorizeRoomRestore === 'function'
|
|
70
|
+
&& await options.authorizeRoomRestore({ app, socket: toSocket, room })
|
|
71
|
+
if (allowed) toSocket.join(room)
|
|
72
|
+
}
|
|
73
|
+
// copy data
|
|
74
|
+
toSocket.data = {
|
|
75
|
+
...data[fromSocketId],
|
|
76
|
+
...toSocket.data,
|
|
77
|
+
__cnxTransferToken: transferToken,
|
|
78
|
+
}
|
|
79
|
+
// console.log('cnx-transfer data', toSocket.data)
|
|
80
|
+
// console.log('cnx-transfer rooms', toSocket.rooms)
|
|
81
|
+
// remove 'from' cache data
|
|
82
|
+
delete rooms[fromSocketId]
|
|
83
|
+
delete data[fromSocketId]
|
|
84
|
+
delete transferTokens[fromSocketId]
|
|
85
|
+
clearTimeout(transferExpiryTimers[fromSocketId])
|
|
86
|
+
delete transferExpiryTimers[fromSocketId]
|
|
87
|
+
// send acknowlegment to toSocket
|
|
88
|
+
toSocket.emit('cnx-transfer-ack', fromSocketId, toSocketId)
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`*** CNX TRANSFER ERROR, ${fromSocketId} -> ${toSocketId}`)
|
|
91
|
+
if (toSocket) toSocket.emit('cnx-transfer-error', fromSocketId, toSocketId)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { electricClientPlugin, whereToElectricParams } from '../src/electric-client-plugin.mjs'
|
|
5
|
+
|
|
6
|
+
class FakeStream {
|
|
7
|
+
static instances = []
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.options = options
|
|
10
|
+
FakeStream.instances.push(this)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class FakeShape {
|
|
15
|
+
constructor(stream) { this.stream = stream }
|
|
16
|
+
subscribe(callback) {
|
|
17
|
+
callback({ rows: [{ uid: 'one', completed: false }] })
|
|
18
|
+
return () => { this.unsubscribed = true }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('translates where objects into parameterized Electric filters', () => {
|
|
23
|
+
assert.deepEqual(whereToElectricParams({ completed: false, priority: { gte: 2, lt: 5 }, owner: null }), {
|
|
24
|
+
where: '"completed" = $1 AND "priority" >= $2 AND "priority" < $3 AND "owner" IS NULL',
|
|
25
|
+
params: ['false', '2', '5'],
|
|
26
|
+
})
|
|
27
|
+
assert.deepEqual(whereToElectricParams({}), {})
|
|
28
|
+
assert.throws(() => whereToElectricParams({ 'bad;drop': 1 }), /simple SQL identifier/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('getObservable emits Shape rows and cleans up its subscription', () => {
|
|
32
|
+
const app = { service: () => ({}) }
|
|
33
|
+
electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
|
|
34
|
+
const todo = app.createElectricModel('todos')
|
|
35
|
+
let rows
|
|
36
|
+
const subscription = todo.getObservable({ completed: false }).subscribe(value => { rows = value })
|
|
37
|
+
|
|
38
|
+
assert.deepEqual(rows, [{ uid: 'one', completed: false }])
|
|
39
|
+
assert.equal(FakeStream.instances.at(-1).options.url, '/electric/v1/shape/todos')
|
|
40
|
+
assert.deepEqual(FakeStream.instances.at(-1).options.params, {
|
|
41
|
+
where: '"completed" = $1', params: ['false'],
|
|
42
|
+
})
|
|
43
|
+
subscription.unsubscribe()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('model mutations retain the simple Express-X API', async () => {
|
|
47
|
+
const calls = []
|
|
48
|
+
const service = {
|
|
49
|
+
async createWithMeta(...args) { calls.push(['create', ...args]); return [{ uid: args[0], ...args[1] }, {}] },
|
|
50
|
+
async updateWithMeta(...args) { calls.push(['update', ...args]); return [{ uid: args[0], ...args[1] }, {}] },
|
|
51
|
+
async deleteWithMeta(...args) { calls.push(['remove', ...args]); return [{ uid: args[0] }, {}] },
|
|
52
|
+
}
|
|
53
|
+
const app = { service: () => service }
|
|
54
|
+
electricClientPlugin(app, { ShapeStream: FakeStream, Shape: FakeShape })
|
|
55
|
+
const todo = app.createElectricModel('todos')
|
|
56
|
+
|
|
57
|
+
const created = await todo.create({ title: 'Test' })
|
|
58
|
+
await todo.update(created.uid, { completed: true })
|
|
59
|
+
await todo.remove(created.uid)
|
|
60
|
+
assert.deepEqual(calls.map(call => call[0]), ['create', 'update', 'remove'])
|
|
61
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { electricOfflinePlugin } from '../src/electric-server-plugin.mjs'
|
|
5
|
+
|
|
6
|
+
function fixture({ authorize = async () => true, fetch } = {}) {
|
|
7
|
+
const services = new Map()
|
|
8
|
+
const routes = new Map()
|
|
9
|
+
const queries = []
|
|
10
|
+
const db = {
|
|
11
|
+
async query(sql, values = []) {
|
|
12
|
+
queries.push({ sql, values })
|
|
13
|
+
if (sql.startsWith('SELECT pg_current')) return { rows: [{ txid: '42' }] }
|
|
14
|
+
return { rows: [{ uid: values.at(-1) ?? 'one', label: 'row' }] }
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
const app = {
|
|
18
|
+
createService(name, methods) { services.set(name, methods) },
|
|
19
|
+
get(path, handler) { routes.set(path, handler) },
|
|
20
|
+
}
|
|
21
|
+
const registration = electricOfflinePlugin(app, db, ['todos'], { authorize, fetch: fetch ?? globalThis.fetch })
|
|
22
|
+
return { app, db, services, routes, queries, registration }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('registers the familiar mutation API', async () => {
|
|
26
|
+
const { services, queries } = fixture()
|
|
27
|
+
const service = services.get('todos')
|
|
28
|
+
assert.deepEqual(Object.keys(service), [
|
|
29
|
+
'createWithMeta', 'updateWithMeta', 'deleteWithMeta',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
const [, meta] = await service.createWithMeta.call({}, 'one', { label: 'new' }, '2026-01-01T00:00:00Z')
|
|
33
|
+
assert.equal(meta.uid, 'one')
|
|
34
|
+
assert.equal(meta.txid, '42')
|
|
35
|
+
assert.equal(meta.created_at, '2026-01-01T00:00:00.000Z')
|
|
36
|
+
|
|
37
|
+
await service.createWithMeta.call({}, 'only-id', {}, '2026-01-01T00:00:00Z')
|
|
38
|
+
assert.match(queries[2].sql, /DO UPDATE SET "uid" = EXCLUDED\."uid"/)
|
|
39
|
+
await assert.rejects(service.createWithMeta.call({}, 'bad', [], new Date()), /plain object/)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('requires authorization and reports forbidden calls', async () => {
|
|
43
|
+
const { services } = fixture({ authorize: async () => false })
|
|
44
|
+
await assert.rejects(
|
|
45
|
+
services.get('todos').createWithMeta.call({}, 'one', { label: 'no' }, new Date()),
|
|
46
|
+
error => error.code === 'forbidden',
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('shape proxy pins the configured table and keeps credentials server-side', async () => {
|
|
51
|
+
let fetched
|
|
52
|
+
const fetch = async (url) => {
|
|
53
|
+
fetched = new URL(url)
|
|
54
|
+
return new Response('[{"value":{"uid":"one"}}]', {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { 'content-type': 'application/json', 'electric-handle': 'abc' },
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
const services = new Map()
|
|
60
|
+
const routes = new Map()
|
|
61
|
+
const db = { query: async () => ({ rows: [] }) }
|
|
62
|
+
const app = {
|
|
63
|
+
createService(name, methods) { services.set(name, methods) },
|
|
64
|
+
get(path, handler) { routes.set(path, handler) },
|
|
65
|
+
}
|
|
66
|
+
electricOfflinePlugin(app, db, [{ name: 'todo', table: 'todos' }], {
|
|
67
|
+
authorize: async () => true,
|
|
68
|
+
electricUrl: 'https://electric.example/v1/shape',
|
|
69
|
+
sourceId: 'source',
|
|
70
|
+
sourceSecret: 'secret',
|
|
71
|
+
fetch,
|
|
72
|
+
})
|
|
73
|
+
const response = {
|
|
74
|
+
headers: {},
|
|
75
|
+
status(value) { this.statusCode = value; return this },
|
|
76
|
+
setHeader(key, value) { this.headers[key] = value },
|
|
77
|
+
send(value) { this.body = value },
|
|
78
|
+
json(value) { this.body = value },
|
|
79
|
+
}
|
|
80
|
+
await routes.get('/electric/v1/shape/:model')({
|
|
81
|
+
params: { model: 'todo' },
|
|
82
|
+
query: { table: 'secrets', offset: '10' },
|
|
83
|
+
get: () => 'application/json',
|
|
84
|
+
}, response, error => { throw error })
|
|
85
|
+
|
|
86
|
+
assert.equal(fetched.searchParams.get('table'), 'todos')
|
|
87
|
+
assert.equal(fetched.searchParams.get('offset'), '10')
|
|
88
|
+
assert.equal(fetched.searchParams.get('source_id'), 'source')
|
|
89
|
+
assert.equal(fetched.searchParams.get('secret'), 'secret')
|
|
90
|
+
assert.equal(response.statusCode, 200)
|
|
91
|
+
assert.equal(response.headers['electric-handle'], 'abc')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('rejects unsafe SQL identifiers', () => {
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => electricOfflinePlugin(
|
|
97
|
+
{ createService() {}, get() {} },
|
|
98
|
+
{ query() {} },
|
|
99
|
+
['todos; DROP TABLE users'],
|
|
100
|
+
{ authorize: async () => true, fetch: async () => {} },
|
|
101
|
+
),
|
|
102
|
+
/simple SQL identifier/,
|
|
103
|
+
)
|
|
104
|
+
})
|