convex-helpers 0.0.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.
@@ -0,0 +1 @@
1
+ {"type": "commonjs"}
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var relationships_exports = {};
20
+ __export(relationships_exports, {
21
+ asyncMap: () => asyncMap,
22
+ getAll: () => getAll,
23
+ getAllWithNulls: () => getAllWithNulls,
24
+ getManyFrom: () => getManyFrom,
25
+ getManyVia: () => getManyVia,
26
+ getOneFrom: () => getOneFrom,
27
+ getOneOrNullFrom: () => getOneOrNullFrom,
28
+ pruneNull: () => pruneNull
29
+ });
30
+ module.exports = __toCommonJS(relationships_exports);
31
+ var import_values = require("convex/values");
32
+ async function asyncMap(list, asyncTransform) {
33
+ const promises = [];
34
+ for (const item of list) {
35
+ promises.push(asyncTransform(item));
36
+ }
37
+ return Promise.all(promises);
38
+ }
39
+ async function getAll(db, ids) {
40
+ return pruneNull(await asyncMap(ids, db.get));
41
+ }
42
+ async function getAllWithNulls(db, ids) {
43
+ return asyncMap(ids, db.get);
44
+ }
45
+ async function getOneFrom(db, table, field, value) {
46
+ const ret = await db.query(table).withIndex("by_" + field, (q) => q.eq(field, value)).unique();
47
+ if (ret === null) {
48
+ throw new import_values.ConvexError(
49
+ `Can't find a document in ${table} with field ${field} equal to ${value}`
50
+ );
51
+ }
52
+ return ret;
53
+ }
54
+ async function getOneOrNullFrom(db, table, field, value) {
55
+ return db.query(table).withIndex("by_" + field, (q) => q.eq(field, value)).unique();
56
+ }
57
+ async function getManyFrom(db, table, field, value) {
58
+ return db.query(table).withIndex("by_" + field, (q) => q.eq(field, value)).collect();
59
+ }
60
+ async function getManyVia(db, table, toField, fromField, value) {
61
+ return pruneNull(
62
+ await asyncMap(
63
+ await getManyFrom(db, table, fromField, value),
64
+ (link) => db.get(link[toField])
65
+ )
66
+ );
67
+ }
68
+ function pruneNull(list) {
69
+ return list.filter((i) => i !== null);
70
+ }
71
+ // Annotate the CommonJS export names for ESM import in node:
72
+ 0 && (module.exports = {
73
+ asyncMap,
74
+ getAll,
75
+ getAllWithNulls,
76
+ getManyFrom,
77
+ getManyVia,
78
+ getOneFrom,
79
+ getOneOrNullFrom,
80
+ pruneNull
81
+ });
@@ -0,0 +1,273 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var rowLevelSecurity_exports = {};
20
+ __export(rowLevelSecurity_exports, {
21
+ RowLevelSecurity: () => RowLevelSecurity
22
+ });
23
+ module.exports = __toCommonJS(rowLevelSecurity_exports);
24
+ const RowLevelSecurity = (rules) => {
25
+ const withMutationRLS = (f) => {
26
+ return (ctx, ...args) => {
27
+ const wrappedDb = new WrapWriter(ctx, ctx.db, rules);
28
+ return f({ ...ctx, db: wrappedDb }, ...args);
29
+ };
30
+ };
31
+ const withQueryRLS = (f) => {
32
+ return (ctx, ...args) => {
33
+ const wrappedDb = new WrapReader(ctx, ctx.db, rules);
34
+ return f({ ...ctx, db: wrappedDb }, ...args);
35
+ };
36
+ };
37
+ return {
38
+ withMutationRLS,
39
+ withQueryRLS
40
+ };
41
+ };
42
+ async function asyncFilter(arr, predicate) {
43
+ const results = await Promise.all(arr.map(predicate));
44
+ return arr.filter((_v, index) => results[index]);
45
+ }
46
+ class WrapQuery {
47
+ q;
48
+ p;
49
+ iterator;
50
+ constructor(q, p) {
51
+ this.q = q;
52
+ this.p = p;
53
+ }
54
+ filter(predicate) {
55
+ return new WrapQuery(this.q.filter(predicate), this.p);
56
+ }
57
+ order(order) {
58
+ return new WrapQuery(this.q.order(order), this.p);
59
+ }
60
+ async paginate(paginationOpts) {
61
+ const result = await this.q.paginate(paginationOpts);
62
+ result.page = await asyncFilter(result.page, this.p);
63
+ return result;
64
+ }
65
+ async collect() {
66
+ const results = await this.q.collect();
67
+ return await asyncFilter(results, this.p);
68
+ }
69
+ async take(n) {
70
+ const results = [];
71
+ for await (const result of this) {
72
+ results.push(result);
73
+ if (results.length >= n) {
74
+ break;
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+ async first() {
80
+ for await (const result of this) {
81
+ return result;
82
+ }
83
+ return null;
84
+ }
85
+ async unique() {
86
+ let uniqueResult = null;
87
+ for await (const result of this) {
88
+ if (uniqueResult === null) {
89
+ uniqueResult = result;
90
+ } else {
91
+ throw new Error("not unique");
92
+ }
93
+ }
94
+ return uniqueResult;
95
+ }
96
+ [Symbol.asyncIterator]() {
97
+ this.iterator = this.q[Symbol.asyncIterator]();
98
+ return this;
99
+ }
100
+ async next() {
101
+ for (; ; ) {
102
+ const { value, done } = await this.iterator.next();
103
+ if (value && await this.p(value)) {
104
+ return { value, done };
105
+ }
106
+ if (done) {
107
+ return { value: null, done: true };
108
+ }
109
+ }
110
+ }
111
+ return() {
112
+ return this.iterator.return();
113
+ }
114
+ }
115
+ class WrapQueryInitializer {
116
+ q;
117
+ p;
118
+ constructor(q, p) {
119
+ this.q = q;
120
+ this.p = p;
121
+ }
122
+ fullTableScan() {
123
+ return new WrapQuery(this.q.fullTableScan(), this.p);
124
+ }
125
+ withIndex(indexName, indexRange) {
126
+ return new WrapQuery(this.q.withIndex(indexName, indexRange), this.p);
127
+ }
128
+ withSearchIndex(indexName, searchFilter) {
129
+ return new WrapQuery(
130
+ this.q.withSearchIndex(indexName, searchFilter),
131
+ this.p
132
+ );
133
+ }
134
+ filter(predicate) {
135
+ return this.fullTableScan().filter(predicate);
136
+ }
137
+ order(order) {
138
+ return this.fullTableScan().order(order);
139
+ }
140
+ async paginate(paginationOpts) {
141
+ return this.fullTableScan().paginate(paginationOpts);
142
+ }
143
+ collect() {
144
+ return this.fullTableScan().collect();
145
+ }
146
+ take(n) {
147
+ return this.fullTableScan().take(n);
148
+ }
149
+ first() {
150
+ return this.fullTableScan().first();
151
+ }
152
+ unique() {
153
+ return this.fullTableScan().unique();
154
+ }
155
+ [Symbol.asyncIterator]() {
156
+ return this.fullTableScan()[Symbol.asyncIterator]();
157
+ }
158
+ }
159
+ class WrapReader {
160
+ ctx;
161
+ db;
162
+ rules;
163
+ constructor(ctx, db, rules) {
164
+ this.ctx = ctx;
165
+ this.db = db;
166
+ this.rules = rules;
167
+ }
168
+ normalizeId(tableName, id) {
169
+ return this.db.normalizeId(tableName, id);
170
+ }
171
+ tableName(id) {
172
+ for (const tableName of Object.keys(this.rules)) {
173
+ if (this.db.normalizeId(tableName, id)) {
174
+ return tableName;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+ async predicate(tableName, doc) {
180
+ if (!this.rules[tableName]?.read) {
181
+ return true;
182
+ }
183
+ return await this.rules[tableName].read(this.ctx, doc);
184
+ }
185
+ async get(id) {
186
+ const doc = await this.db.get(id);
187
+ if (doc) {
188
+ const tableName = this.tableName(id);
189
+ if (tableName && !await this.predicate(tableName, doc)) {
190
+ return null;
191
+ }
192
+ return doc;
193
+ }
194
+ return null;
195
+ }
196
+ query(tableName) {
197
+ return new WrapQueryInitializer(
198
+ this.db.query(tableName),
199
+ async (d) => await this.predicate(tableName, d)
200
+ );
201
+ }
202
+ }
203
+ class WrapWriter {
204
+ ctx;
205
+ db;
206
+ reader;
207
+ rules;
208
+ async modifyPredicate(tableName, doc) {
209
+ if (!this.rules[tableName]?.modify) {
210
+ return true;
211
+ }
212
+ return await this.rules[tableName].modify(this.ctx, doc);
213
+ }
214
+ constructor(ctx, db, rules) {
215
+ this.ctx = ctx;
216
+ this.db = db;
217
+ this.reader = new WrapReader(ctx, db, rules);
218
+ this.rules = rules;
219
+ }
220
+ normalizeId(tableName, id) {
221
+ return this.db.normalizeId(tableName, id);
222
+ }
223
+ async insert(table, value) {
224
+ const rules = this.rules[table];
225
+ if (rules?.insert && !await rules.insert(this.ctx, value)) {
226
+ throw new Error("insert access not allowed");
227
+ }
228
+ return await this.db.insert(table, value);
229
+ }
230
+ tableName(id) {
231
+ for (const tableName of Object.keys(this.rules)) {
232
+ if (this.db.normalizeId(tableName, id)) {
233
+ return tableName;
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+ async checkAuth(id) {
239
+ const doc = await this.get(id);
240
+ if (doc === null) {
241
+ throw new Error("no read access or doc does not exist");
242
+ }
243
+ const tableName = this.tableName(id);
244
+ if (tableName === null) {
245
+ return;
246
+ }
247
+ if (!await this.modifyPredicate(tableName, doc)) {
248
+ throw new Error("write access not allowed");
249
+ }
250
+ }
251
+ async patch(id, value) {
252
+ await this.checkAuth(id);
253
+ return await this.db.patch(id, value);
254
+ }
255
+ async replace(id, value) {
256
+ await this.checkAuth(id);
257
+ return await this.db.replace(id, value);
258
+ }
259
+ async delete(id) {
260
+ await this.checkAuth(id);
261
+ return await this.db.delete(id);
262
+ }
263
+ get(id) {
264
+ return this.reader.get(id);
265
+ }
266
+ query(tableName) {
267
+ return this.reader.query(tableName);
268
+ }
269
+ }
270
+ // Annotate the CommonJS export names for ESM import in node:
271
+ 0 && (module.exports = {
272
+ RowLevelSecurity
273
+ });
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ import { ConvexError } from "convex/values";
3
+ export async function asyncMap(list, asyncTransform) {
4
+ const promises = [];
5
+ for (const item of list) {
6
+ promises.push(asyncTransform(item));
7
+ }
8
+ return Promise.all(promises);
9
+ }
10
+ export async function getAll(db, ids) {
11
+ return pruneNull(await asyncMap(ids, db.get));
12
+ }
13
+ export async function getAllWithNulls(db, ids) {
14
+ return asyncMap(ids, db.get);
15
+ }
16
+ export async function getOneFrom(db, table, field, value) {
17
+ const ret = await db.query(table).withIndex("by_" + field, (q) => q.eq(field, value)).unique();
18
+ if (ret === null) {
19
+ throw new ConvexError(
20
+ `Can't find a document in ${table} with field ${field} equal to ${value}`
21
+ );
22
+ }
23
+ return ret;
24
+ }
25
+ export async function getOneOrNullFrom(db, table, field, value) {
26
+ return db.query(table).withIndex("by_" + field, (q) => q.eq(field, value)).unique();
27
+ }
28
+ export async function getManyFrom(db, table, field, value) {
29
+ return db.query(table).withIndex("by_" + field, (q) => q.eq(field, value)).collect();
30
+ }
31
+ export async function getManyVia(db, table, toField, fromField, value) {
32
+ return pruneNull(
33
+ await asyncMap(
34
+ await getManyFrom(db, table, fromField, value),
35
+ (link) => db.get(link[toField])
36
+ )
37
+ );
38
+ }
39
+ export function pruneNull(list) {
40
+ return list.filter((i) => i !== null);
41
+ }
@@ -0,0 +1,247 @@
1
+ "use strict";
2
+ export const RowLevelSecurity = (rules) => {
3
+ const withMutationRLS = (f) => {
4
+ return (ctx, ...args) => {
5
+ const wrappedDb = new WrapWriter(ctx, ctx.db, rules);
6
+ return f({ ...ctx, db: wrappedDb }, ...args);
7
+ };
8
+ };
9
+ const withQueryRLS = (f) => {
10
+ return (ctx, ...args) => {
11
+ const wrappedDb = new WrapReader(ctx, ctx.db, rules);
12
+ return f({ ...ctx, db: wrappedDb }, ...args);
13
+ };
14
+ };
15
+ return {
16
+ withMutationRLS,
17
+ withQueryRLS
18
+ };
19
+ };
20
+ async function asyncFilter(arr, predicate) {
21
+ const results = await Promise.all(arr.map(predicate));
22
+ return arr.filter((_v, index) => results[index]);
23
+ }
24
+ class WrapQuery {
25
+ q;
26
+ p;
27
+ iterator;
28
+ constructor(q, p) {
29
+ this.q = q;
30
+ this.p = p;
31
+ }
32
+ filter(predicate) {
33
+ return new WrapQuery(this.q.filter(predicate), this.p);
34
+ }
35
+ order(order) {
36
+ return new WrapQuery(this.q.order(order), this.p);
37
+ }
38
+ async paginate(paginationOpts) {
39
+ const result = await this.q.paginate(paginationOpts);
40
+ result.page = await asyncFilter(result.page, this.p);
41
+ return result;
42
+ }
43
+ async collect() {
44
+ const results = await this.q.collect();
45
+ return await asyncFilter(results, this.p);
46
+ }
47
+ async take(n) {
48
+ const results = [];
49
+ for await (const result of this) {
50
+ results.push(result);
51
+ if (results.length >= n) {
52
+ break;
53
+ }
54
+ }
55
+ return results;
56
+ }
57
+ async first() {
58
+ for await (const result of this) {
59
+ return result;
60
+ }
61
+ return null;
62
+ }
63
+ async unique() {
64
+ let uniqueResult = null;
65
+ for await (const result of this) {
66
+ if (uniqueResult === null) {
67
+ uniqueResult = result;
68
+ } else {
69
+ throw new Error("not unique");
70
+ }
71
+ }
72
+ return uniqueResult;
73
+ }
74
+ [Symbol.asyncIterator]() {
75
+ this.iterator = this.q[Symbol.asyncIterator]();
76
+ return this;
77
+ }
78
+ async next() {
79
+ for (; ; ) {
80
+ const { value, done } = await this.iterator.next();
81
+ if (value && await this.p(value)) {
82
+ return { value, done };
83
+ }
84
+ if (done) {
85
+ return { value: null, done: true };
86
+ }
87
+ }
88
+ }
89
+ return() {
90
+ return this.iterator.return();
91
+ }
92
+ }
93
+ class WrapQueryInitializer {
94
+ q;
95
+ p;
96
+ constructor(q, p) {
97
+ this.q = q;
98
+ this.p = p;
99
+ }
100
+ fullTableScan() {
101
+ return new WrapQuery(this.q.fullTableScan(), this.p);
102
+ }
103
+ withIndex(indexName, indexRange) {
104
+ return new WrapQuery(this.q.withIndex(indexName, indexRange), this.p);
105
+ }
106
+ withSearchIndex(indexName, searchFilter) {
107
+ return new WrapQuery(
108
+ this.q.withSearchIndex(indexName, searchFilter),
109
+ this.p
110
+ );
111
+ }
112
+ filter(predicate) {
113
+ return this.fullTableScan().filter(predicate);
114
+ }
115
+ order(order) {
116
+ return this.fullTableScan().order(order);
117
+ }
118
+ async paginate(paginationOpts) {
119
+ return this.fullTableScan().paginate(paginationOpts);
120
+ }
121
+ collect() {
122
+ return this.fullTableScan().collect();
123
+ }
124
+ take(n) {
125
+ return this.fullTableScan().take(n);
126
+ }
127
+ first() {
128
+ return this.fullTableScan().first();
129
+ }
130
+ unique() {
131
+ return this.fullTableScan().unique();
132
+ }
133
+ [Symbol.asyncIterator]() {
134
+ return this.fullTableScan()[Symbol.asyncIterator]();
135
+ }
136
+ }
137
+ class WrapReader {
138
+ ctx;
139
+ db;
140
+ rules;
141
+ constructor(ctx, db, rules) {
142
+ this.ctx = ctx;
143
+ this.db = db;
144
+ this.rules = rules;
145
+ }
146
+ normalizeId(tableName, id) {
147
+ return this.db.normalizeId(tableName, id);
148
+ }
149
+ tableName(id) {
150
+ for (const tableName of Object.keys(this.rules)) {
151
+ if (this.db.normalizeId(tableName, id)) {
152
+ return tableName;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ async predicate(tableName, doc) {
158
+ if (!this.rules[tableName]?.read) {
159
+ return true;
160
+ }
161
+ return await this.rules[tableName].read(this.ctx, doc);
162
+ }
163
+ async get(id) {
164
+ const doc = await this.db.get(id);
165
+ if (doc) {
166
+ const tableName = this.tableName(id);
167
+ if (tableName && !await this.predicate(tableName, doc)) {
168
+ return null;
169
+ }
170
+ return doc;
171
+ }
172
+ return null;
173
+ }
174
+ query(tableName) {
175
+ return new WrapQueryInitializer(
176
+ this.db.query(tableName),
177
+ async (d) => await this.predicate(tableName, d)
178
+ );
179
+ }
180
+ }
181
+ class WrapWriter {
182
+ ctx;
183
+ db;
184
+ reader;
185
+ rules;
186
+ async modifyPredicate(tableName, doc) {
187
+ if (!this.rules[tableName]?.modify) {
188
+ return true;
189
+ }
190
+ return await this.rules[tableName].modify(this.ctx, doc);
191
+ }
192
+ constructor(ctx, db, rules) {
193
+ this.ctx = ctx;
194
+ this.db = db;
195
+ this.reader = new WrapReader(ctx, db, rules);
196
+ this.rules = rules;
197
+ }
198
+ normalizeId(tableName, id) {
199
+ return this.db.normalizeId(tableName, id);
200
+ }
201
+ async insert(table, value) {
202
+ const rules = this.rules[table];
203
+ if (rules?.insert && !await rules.insert(this.ctx, value)) {
204
+ throw new Error("insert access not allowed");
205
+ }
206
+ return await this.db.insert(table, value);
207
+ }
208
+ tableName(id) {
209
+ for (const tableName of Object.keys(this.rules)) {
210
+ if (this.db.normalizeId(tableName, id)) {
211
+ return tableName;
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+ async checkAuth(id) {
217
+ const doc = await this.get(id);
218
+ if (doc === null) {
219
+ throw new Error("no read access or doc does not exist");
220
+ }
221
+ const tableName = this.tableName(id);
222
+ if (tableName === null) {
223
+ return;
224
+ }
225
+ if (!await this.modifyPredicate(tableName, doc)) {
226
+ throw new Error("write access not allowed");
227
+ }
228
+ }
229
+ async patch(id, value) {
230
+ await this.checkAuth(id);
231
+ return await this.db.patch(id, value);
232
+ }
233
+ async replace(id, value) {
234
+ await this.checkAuth(id);
235
+ return await this.db.replace(id, value);
236
+ }
237
+ async delete(id) {
238
+ await this.checkAuth(id);
239
+ return await this.db.delete(id);
240
+ }
241
+ get(id) {
242
+ return this.reader.get(id);
243
+ }
244
+ query(tableName) {
245
+ return this.reader.query(tableName);
246
+ }
247
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "convex-helpers",
3
+ "version": "0.0.1",
4
+ "description": "A collection of useful code to complement the official convex package.",
5
+ "exports": {
6
+ "./server": {
7
+ "types": "./server/",
8
+ "import": "./esm/server/",
9
+ "require": "./cjs/server/"
10
+ }
11
+ },
12
+ "files": [
13
+ "esm",
14
+ "cjs",
15
+ "server"
16
+ ],
17
+ "scripts": {
18
+ "build": "npm run clean && npm run build:esm 2>/dev/null && npm run build:cjs 2>/dev/null",
19
+ "clean": "rm -rf cjs esm",
20
+ "build:esm": "esbuild $(find server -iname \"*.ts\") --outdir=esm --outbase=. --loader:.ts=ts --platform=node --bundle=false",
21
+ "build:cjs": "esbuild $(find server -iname \"*.ts\") --outdir=cjs --outbase=. --loader:.ts=ts --platform=node --bundle=false --format=cjs && echo '{\"type\": \"commonjs\"}' > cjs/package.json",
22
+ "watch": "chokidar 'server/**/*.tsx' 'server/**/*.ts' 'package.json' -c 'npm run build:esm' --initial",
23
+ "test": "echo \"Error: no test specified\" && exit 1"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/get-convex/convex-helpers.git"
28
+ },
29
+ "keywords": [
30
+ "convex",
31
+ "database",
32
+ "react"
33
+ ],
34
+ "author": "Ian Macartney <ian@convex.dev>",
35
+ "license": "MIT",
36
+ "bugs": {
37
+ "url": "https://github.com/get-convex/convex-helpers/issues"
38
+ },
39
+ "homepage": "https://github.com/get-convex/convex-helpers#readme",
40
+ "peerDependencies": {
41
+ "convex": "1.0 - 1.5"
42
+ },
43
+ "devDependencies": {
44
+ "chokidar-cli": "^3.0.0",
45
+ "typescript": "^5.2.2"
46
+ }
47
+ }
@@ -0,0 +1,272 @@
1
+ import {
2
+ FieldTypeFromFieldPath,
3
+ Indexes,
4
+ NamedTableInfo,
5
+ TableNamesInDataModel,
6
+ GenericDataModel,
7
+ GenericDatabaseReader,
8
+ DocumentByName,
9
+ } from "convex/server";
10
+ import { ConvexError, GenericId } from "convex/values";
11
+
12
+ /**
13
+ * asyncMap returns the results of applying an async function over an list.
14
+ *
15
+ * @param list - Iterable object of items, e.g. an Array, Set, Object.keys
16
+ * @param asyncTransform
17
+ * @returns
18
+ */
19
+ export async function asyncMap<FromType, ToType>(
20
+ list: Iterable<FromType>,
21
+ asyncTransform: (item: FromType) => Promise<ToType>
22
+ ): Promise<ToType[]> {
23
+ const promises: Promise<ToType>[] = [];
24
+ for (const item of list) {
25
+ promises.push(asyncTransform(item));
26
+ }
27
+ return Promise.all(promises);
28
+ }
29
+
30
+ /**
31
+ * getAll returns a list of Documents corresponding to the `Id`s passed in.
32
+ * @param db A database object, usually passed from a mutation or query ctx.
33
+ * @param ids An list (or other iterable) of Ids pointing to a table.
34
+ * @returns The Documents referenced by the Ids, in order.
35
+ */
36
+ export async function getAll<
37
+ DataModel extends GenericDataModel,
38
+ TableName extends string = TableNamesInDataModel<DataModel>
39
+ >(
40
+ db: GenericDatabaseReader<DataModel>,
41
+ ids: GenericId<TableName>[]
42
+ ): Promise<(DocumentByName<DataModel, TableName> | null)[]> {
43
+ return pruneNull(await asyncMap(ids, db.get));
44
+ }
45
+
46
+ /**
47
+ * getAllOrNone returns a list of Documents or null for the `Id`s passed in.
48
+ * @param db A database object, usually passed from a mutation or query ctx.
49
+ * @param ids An list (or other iterable) of Ids pointing to a table.
50
+ * @returns The Documents referenced by the Ids, in order. `null` if not found.
51
+ */
52
+ export async function getAllWithNulls<
53
+ DataModel extends GenericDataModel,
54
+ TableName extends string = TableNamesInDataModel<DataModel>
55
+ >(
56
+ db: GenericDatabaseReader<DataModel>,
57
+ ids: GenericId<TableName>[]
58
+ ): Promise<(DocumentByName<DataModel, TableName> | null)[]> {
59
+ return asyncMap(ids, db.get);
60
+ }
61
+
62
+ // `FieldPath`s that have a `"FieldPath"` index on [`FieldPath`, ...]
63
+ // type LookupFieldPaths<TableName extends TableNames> = {[FieldPath in DataModel[TableName]["fieldPaths"]]: FieldPath extends keyof DataModel[TableName]["indexes"]? Indexes<NamedTableInfo<DataModel, TableName>>[FieldPath][0] extends FieldPath ? FieldPath : never: never}[DataModel[TableName]["fieldPaths"]]
64
+
65
+ // `FieldPath`s that have a `"by_${FieldPath}""` index on [`FieldPath`, ...]
66
+ type LookupFieldPaths<
67
+ DataModel extends GenericDataModel,
68
+ TableName extends string = TableNamesInDataModel<DataModel>
69
+ > = {
70
+ [FieldPath in DataModel[TableName]["fieldPaths"]]: `by_${FieldPath}` extends keyof DataModel[TableName]["indexes"]
71
+ ? Indexes<
72
+ NamedTableInfo<DataModel, TableName>
73
+ >[`by_${FieldPath}`][0] extends FieldPath
74
+ ? FieldPath
75
+ : never
76
+ : never;
77
+ }[DataModel[TableName]["fieldPaths"]];
78
+
79
+ type TablesWithLookups<
80
+ DataModel extends GenericDataModel,
81
+ TableNames extends string = TableNamesInDataModel<DataModel>
82
+ > = {
83
+ [TableName in TableNames]: LookupFieldPaths<
84
+ DataModel,
85
+ TableName
86
+ > extends never
87
+ ? never
88
+ : TableName;
89
+ }[TableNames];
90
+
91
+ /**
92
+ * Get a document that references a value with a field indexed `by_${field}`
93
+ *
94
+ * Useful for fetching a document with a one-to-one relationship via backref.
95
+ * @param db DatabaseReader, passed in from the function ctx
96
+ * @param table The table to fetch the target document from.
97
+ * @param field The field on that table that should match the specified value.
98
+ * @param value The value to look up the document by, usually an ID.
99
+ * @returns The document matching the value. Throws if not found.
100
+ */
101
+ export async function getOneFrom<
102
+ DataModel extends GenericDataModel,
103
+ TableName extends TablesWithLookups<DataModel>,
104
+ Field extends LookupFieldPaths<DataModel, TableName>
105
+ >(
106
+ db: GenericDatabaseReader<DataModel>,
107
+ table: TableName,
108
+ field: Field,
109
+ value: FieldTypeFromFieldPath<DocumentByName<DataModel, TableName>, Field>
110
+ ): Promise<DocumentByName<DataModel, TableName>> {
111
+ const ret = await db
112
+ .query(table)
113
+ .withIndex("by_" + field, (q) => q.eq(field, value as any))
114
+ .unique();
115
+ if (ret === null) {
116
+ throw new ConvexError(
117
+ `Can't find a document in ${table} with field ${field} equal to ${value}`
118
+ );
119
+ }
120
+ return ret;
121
+ }
122
+
123
+ /**
124
+ * Get a document that references a value with a field indexed `by_${field}`
125
+ *
126
+ * Useful for fetching a document with a one-to-one relationship via backref.
127
+ * @param db DatabaseReader, passed in from the function ctx
128
+ * @param table The table to fetch the target document from.
129
+ * @param field The field on that table that should match the specified value.
130
+ * @param value The value to look up the document by, usually an ID.
131
+ * @returns The document matching the value, or null if none found.
132
+ */
133
+ export async function getOneOrNullFrom<
134
+ DataModel extends GenericDataModel,
135
+ TableName extends TablesWithLookups<DataModel>,
136
+ Field extends LookupFieldPaths<DataModel, TableName>
137
+ >(
138
+ db: GenericDatabaseReader<DataModel>,
139
+ table: TableName,
140
+ field: Field,
141
+ value: FieldTypeFromFieldPath<DocumentByName<DataModel, TableName>, Field>
142
+ ): Promise<DocumentByName<DataModel, TableName> | null> {
143
+ return db
144
+ .query(table)
145
+ .withIndex("by_" + field, (q) => q.eq(field, value as any))
146
+ .unique();
147
+ }
148
+
149
+ /**
150
+ * Get a list of documents matching a value with a field indexed `by_${field}`.
151
+ *
152
+ * Useful for fetching many documents related to a given value via backrefs.
153
+ * @param db DatabaseReader, passed in from the function ctx
154
+ * @param table The table to fetch the target document from.
155
+ * @param field The field on that table that should match the specified value.
156
+ * @param value The value to look up the document by, usually an ID.
157
+ * @returns The documents matching the value, if any.
158
+ */
159
+ export async function getManyFrom<
160
+ DataModel extends GenericDataModel,
161
+ TableName extends TablesWithLookups<DataModel>,
162
+ Field extends LookupFieldPaths<DataModel, TableName>
163
+ >(
164
+ db: GenericDatabaseReader<DataModel>,
165
+ table: TableName,
166
+ field: Field,
167
+ value: FieldTypeFromFieldPath<DocumentByName<DataModel, TableName>, Field>
168
+ ): Promise<DocumentByName<DataModel, TableName>[]> {
169
+ return db
170
+ .query(table)
171
+ .withIndex("by_" + field, (q) => q.eq(field, value as any))
172
+ .collect();
173
+ }
174
+
175
+ // File paths to fields that are IDs, excluding "_id".
176
+ type IdFilePaths<
177
+ DataModel extends GenericDataModel,
178
+ InTableName extends TablesWithLookups<DataModel>,
179
+ TableName extends TableNamesInDataModel<DataModel>
180
+ > = {
181
+ [FieldName in DataModel[InTableName]["fieldPaths"]]: FieldTypeFromFieldPath<
182
+ DocumentByName<DataModel, InTableName>,
183
+ FieldName
184
+ > extends GenericId<TableName>
185
+ ? FieldName extends "_id"
186
+ ? never
187
+ : FieldName
188
+ : never;
189
+ }[DataModel[InTableName]["fieldPaths"]];
190
+
191
+ // Whether a table has an ID field that isn't its sole lookup field.
192
+ // These can operate as join tables, going from one table to another.
193
+ // One field has an indexed field for lookup, and another has the ID to get.
194
+ type LookupAndIdFilePaths<
195
+ DataModel extends GenericDataModel,
196
+ TableName extends TablesWithLookups<DataModel>
197
+ > = {
198
+ [FieldPath in IdFilePaths<
199
+ DataModel,
200
+ TableName,
201
+ TableNamesInDataModel<DataModel>
202
+ >]: LookupFieldPaths<DataModel, TableName> extends FieldPath ? never : true;
203
+ }[IdFilePaths<DataModel, TableName, TableNamesInDataModel<DataModel>>];
204
+
205
+ // The table names that match LookupAndIdFields.
206
+ // These are the possible "join" or "edge" or "relationship" tables.
207
+ type JoinTables<DataModel extends GenericDataModel> = {
208
+ [TableName in TablesWithLookups<DataModel>]: LookupAndIdFilePaths<
209
+ DataModel,
210
+ TableName
211
+ > extends never
212
+ ? never
213
+ : TableName;
214
+ }[TablesWithLookups<DataModel>];
215
+
216
+ // many-to-many via lookup table
217
+ /**
218
+ * Get related documents by using a join table.
219
+ *
220
+ * It will find all join table entries matching a value, then look up all the
221
+ * documents pointed to by the join table entries. Useful for many-to-many
222
+ * relationships.
223
+ * @param db DatabaseReader, passed in from the function ctx
224
+ * @param table The table to fetch the target document from.
225
+ * @param toField The ID field on the table pointing at target documents.
226
+ * @param fromField The field on the table to compare to the value.
227
+ * @param value The value to match the fromField on the table, usually an ID.
228
+ * @returns The documents targeted by matching documents in the table, if any.
229
+ */
230
+ export async function getManyVia<
231
+ DataModel extends GenericDataModel,
232
+ JoinTableName extends JoinTables<DataModel>,
233
+ ToField extends IdFilePaths<
234
+ DataModel,
235
+ JoinTableName,
236
+ TableNamesInDataModel<DataModel>
237
+ >,
238
+ FromField extends Exclude<
239
+ LookupFieldPaths<DataModel, JoinTableName>,
240
+ ToField
241
+ >,
242
+ TargetTableName extends FieldTypeFromFieldPath<
243
+ DocumentByName<DataModel, JoinTableName>,
244
+ ToField
245
+ > extends GenericId<infer TargetTableName>
246
+ ? TargetTableName
247
+ : never
248
+ >(
249
+ db: GenericDatabaseReader<DataModel>,
250
+ table: JoinTableName,
251
+ toField: ToField,
252
+ fromField: FromField,
253
+ value: FieldTypeFromFieldPath<
254
+ DocumentByName<DataModel, JoinTableName>,
255
+ FromField
256
+ >
257
+ ): Promise<DocumentByName<DataModel, TargetTableName>[]> {
258
+ return pruneNull(
259
+ await asyncMap(await getManyFrom(db, table, fromField, value), (link) =>
260
+ db.get((link as any)[toField])
261
+ )
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Filters out null elements from an array.
267
+ * @param list List of elements that might be null.
268
+ * @returns List of elements with nulls removed.
269
+ */
270
+ export function pruneNull<T>(list: (T | null)[]): T[] {
271
+ return list.filter((i) => i !== null) as T[];
272
+ }
@@ -0,0 +1,443 @@
1
+ import {
2
+ GenericDatabaseReader,
3
+ GenericDatabaseWriter,
4
+ DocumentByInfo,
5
+ DocumentByName,
6
+ Expression,
7
+ FilterBuilder,
8
+ FunctionArgs,
9
+ GenericDataModel,
10
+ GenericTableInfo,
11
+ IndexRange,
12
+ IndexRangeBuilder,
13
+ Indexes,
14
+ GenericMutationCtx,
15
+ NamedIndex,
16
+ NamedSearchIndex,
17
+ NamedTableInfo,
18
+ OrderedQuery,
19
+ PaginationOptions,
20
+ PaginationResult,
21
+ Query,
22
+ GenericQueryCtx,
23
+ QueryInitializer,
24
+ SearchFilter,
25
+ SearchFilterBuilder,
26
+ SearchIndexes,
27
+ TableNamesInDataModel,
28
+ WithoutSystemFields,
29
+ } from "convex/server";
30
+ import { GenericId } from "convex/values";
31
+
32
+ type Rule<Ctx, D> = (ctx: Ctx, doc: D) => Promise<boolean>;
33
+
34
+ export type Rules<Ctx, DataModel extends GenericDataModel> = {
35
+ [T in TableNamesInDataModel<DataModel>]?: {
36
+ read?: Rule<Ctx, DocumentByName<DataModel, T>>;
37
+ modify?: Rule<Ctx, DocumentByName<DataModel, T>>;
38
+ insert?: Rule<Ctx, WithoutSystemFields<DocumentByName<DataModel, T>>>;
39
+ };
40
+ };
41
+
42
+ /**
43
+ * Apply row level security (RLS) to queries and mutations with the returned
44
+ * middleware functions.
45
+ *
46
+ * Example:
47
+ * ```
48
+ * // Defined in a common file so it can be used by all queries and mutations.
49
+ * import { Auth } from "convex/server";
50
+ * import { DataModel } from "./_generated/dataModel";
51
+ * import { DatabaseReader } from "./_generated/server";
52
+ * import { RowLevelSecurity } from "./rowLevelSecurity";
53
+ *
54
+ * export const {withMutationRLS} = RowLevelSecurity<{auth: Auth, db: DatabaseReader}, DataModel>(
55
+ * {
56
+ * cookies: {
57
+ * read: async ({auth}, cookie) => !cookie.eaten,
58
+ * modify: async ({auth, db}, cookie) => {
59
+ * const user = await getUser(auth, db);
60
+ * return user.isParent; // only parents can reach the cookies.
61
+ * },
62
+ * }
63
+ * );
64
+ * // Mutation with row level security enabled.
65
+ * export const eatCookie = mutation(withMutationRLS(
66
+ * async ({db}, {cookieId}) => {
67
+ * // throws "does not exist" error if cookie is already eaten or doesn't exist.
68
+ * // throws "write access" error if authorized user is not a parent.
69
+ * await db.patch(cookieId, {eaten: true});
70
+ * }));
71
+ * ```
72
+ *
73
+ * Notes:
74
+ * * Rules may read any row in `db` -- rules do not apply recursively within the
75
+ * rule functions themselves.
76
+ * * Tables with no rule default to full access.
77
+ * * Middleware functions like `withUser` can be composed with RowLevelSecurity
78
+ * to cache fetches in `ctx`. e.g.
79
+ * ```
80
+ * const {withQueryRLS} = RowLevelSecurity<{user: Doc<"users">}, DataModel>(
81
+ * {
82
+ * cookies: async ({user}, cookie) => user.isParent,
83
+ * }
84
+ * );
85
+ * export default query(withUser(withRLS(...)));
86
+ * ```
87
+ *
88
+ * @param rules - rule for each table, determining whether a row is accessible.
89
+ * - "read" rule says whether a document should be visible.
90
+ * - "modify" rule says whether to throw an error on `replace`, `patch`, and `delete`.
91
+ * - "insert" rule says whether to throw an error on `insert`.
92
+ *
93
+ * @returns Functions `withQueryRLS` and `withMutationRLS` to be passed to
94
+ * `query` or `mutation` respectively.
95
+ * For each row read, modified, or inserted, the security rules are applied.
96
+ */
97
+ export const RowLevelSecurity = <RuleCtx, DataModel extends GenericDataModel>(
98
+ rules: Rules<RuleCtx, DataModel>
99
+ ) => {
100
+ const withMutationRLS = <
101
+ Ctx extends GenericMutationCtx<DataModel>,
102
+ Args extends ArgsArray,
103
+ Output
104
+ >(
105
+ f: Handler<Ctx, Args, Output>
106
+ ): Handler<Ctx, Args, Output> => {
107
+ return ((ctx: any, ...args: any[]) => {
108
+ const wrappedDb = new WrapWriter(ctx, ctx.db, rules);
109
+ return (f as any)({ ...ctx, db: wrappedDb }, ...args);
110
+ }) as Handler<Ctx, Args, Output>;
111
+ };
112
+ const withQueryRLS = <
113
+ Ctx extends GenericQueryCtx<DataModel>,
114
+ Args extends ArgsArray,
115
+ Output
116
+ >(
117
+ f: Handler<Ctx, Args, Output>
118
+ ): Handler<Ctx, Args, Output> => {
119
+ return ((ctx: any, ...args: any[]) => {
120
+ const wrappedDb = new WrapReader(ctx, ctx.db, rules);
121
+ return (f as any)({ ...ctx, db: wrappedDb }, ...args);
122
+ }) as Handler<Ctx, Args, Output>;
123
+ };
124
+ return {
125
+ withMutationRLS,
126
+ withQueryRLS,
127
+ };
128
+ };
129
+
130
+ type ArgsArray = [] | [FunctionArgs<any>];
131
+ type Handler<Ctx, Args extends ArgsArray, Output> = (
132
+ ctx: Ctx,
133
+ ...args: Args
134
+ ) => Output;
135
+
136
+ type AuthPredicate<T extends GenericTableInfo> = (
137
+ doc: DocumentByInfo<T>
138
+ ) => Promise<boolean>;
139
+
140
+ async function asyncFilter<T>(
141
+ arr: T[],
142
+ predicate: (d: T) => Promise<boolean>
143
+ ): Promise<T[]> {
144
+ const results = await Promise.all(arr.map(predicate));
145
+ return arr.filter((_v, index) => results[index]);
146
+ }
147
+
148
+ class WrapQuery<T extends GenericTableInfo> implements Query<T> {
149
+ q: Query<T>;
150
+ p: AuthPredicate<T>;
151
+ iterator?: AsyncIterator<any>;
152
+ constructor(q: Query<T> | OrderedQuery<T>, p: AuthPredicate<T>) {
153
+ this.q = q as Query<T>;
154
+ this.p = p;
155
+ }
156
+ filter(predicate: (q: FilterBuilder<T>) => Expression<boolean>): this {
157
+ return new WrapQuery(this.q.filter(predicate), this.p) as this;
158
+ }
159
+ order(order: "asc" | "desc"): WrapQuery<T> {
160
+ return new WrapQuery(this.q.order(order), this.p);
161
+ }
162
+ async paginate(
163
+ paginationOpts: PaginationOptions
164
+ ): Promise<PaginationResult<DocumentByInfo<T>>> {
165
+ const result = await this.q.paginate(paginationOpts);
166
+ result.page = await asyncFilter(result.page, this.p);
167
+ return result;
168
+ }
169
+ async collect(): Promise<DocumentByInfo<T>[]> {
170
+ const results = await this.q.collect();
171
+ return await asyncFilter(results, this.p);
172
+ }
173
+ async take(n: number): Promise<DocumentByInfo<T>[]> {
174
+ const results = [];
175
+ for await (const result of this) {
176
+ results.push(result);
177
+ if (results.length >= n) {
178
+ break;
179
+ }
180
+ }
181
+ return results;
182
+ }
183
+ async first(): Promise<DocumentByInfo<T> | null> {
184
+ for await (const result of this) {
185
+ return result;
186
+ }
187
+ return null;
188
+ }
189
+ async unique(): Promise<DocumentByInfo<T> | null> {
190
+ let uniqueResult = null;
191
+ for await (const result of this) {
192
+ if (uniqueResult === null) {
193
+ uniqueResult = result;
194
+ } else {
195
+ throw new Error("not unique");
196
+ }
197
+ }
198
+ return uniqueResult;
199
+ }
200
+ [Symbol.asyncIterator](): AsyncIterator<DocumentByInfo<T>, any, undefined> {
201
+ this.iterator = this.q[Symbol.asyncIterator]();
202
+ return this;
203
+ }
204
+ async next(): Promise<IteratorResult<any>> {
205
+ for (;;) {
206
+ const { value, done } = await this.iterator!.next();
207
+ if (value && (await this.p(value))) {
208
+ return { value, done };
209
+ }
210
+ if (done) {
211
+ return { value: null, done: true };
212
+ }
213
+ }
214
+ }
215
+ return() {
216
+ return this.iterator!.return!();
217
+ }
218
+ }
219
+
220
+ class WrapQueryInitializer<T extends GenericTableInfo>
221
+ implements QueryInitializer<T>
222
+ {
223
+ q: QueryInitializer<T>;
224
+ p: AuthPredicate<T>;
225
+ constructor(q: QueryInitializer<T>, p: AuthPredicate<T>) {
226
+ this.q = q;
227
+ this.p = p;
228
+ }
229
+ fullTableScan(): Query<T> {
230
+ return new WrapQuery(this.q.fullTableScan(), this.p);
231
+ }
232
+ withIndex<IndexName extends keyof Indexes<T>>(
233
+ indexName: IndexName,
234
+ indexRange?:
235
+ | ((
236
+ q: IndexRangeBuilder<DocumentByInfo<T>, NamedIndex<T, IndexName>, 0>
237
+ ) => IndexRange)
238
+ | undefined
239
+ ): Query<T> {
240
+ return new WrapQuery(this.q.withIndex(indexName, indexRange), this.p);
241
+ }
242
+ withSearchIndex<IndexName extends keyof SearchIndexes<T>>(
243
+ indexName: IndexName,
244
+ searchFilter: (
245
+ q: SearchFilterBuilder<DocumentByInfo<T>, NamedSearchIndex<T, IndexName>>
246
+ ) => SearchFilter
247
+ ): OrderedQuery<T> {
248
+ return new WrapQuery(
249
+ this.q.withSearchIndex(indexName, searchFilter),
250
+ this.p
251
+ );
252
+ }
253
+ filter(predicate: (q: FilterBuilder<T>) => Expression<boolean>): this {
254
+ return this.fullTableScan().filter(predicate) as this;
255
+ }
256
+ order(order: "asc" | "desc"): OrderedQuery<T> {
257
+ return this.fullTableScan().order(order);
258
+ }
259
+ async paginate(
260
+ paginationOpts: PaginationOptions
261
+ ): Promise<PaginationResult<DocumentByInfo<T>>> {
262
+ return this.fullTableScan().paginate(paginationOpts);
263
+ }
264
+ collect(): Promise<DocumentByInfo<T>[]> {
265
+ return this.fullTableScan().collect();
266
+ }
267
+ take(n: number): Promise<DocumentByInfo<T>[]> {
268
+ return this.fullTableScan().take(n);
269
+ }
270
+ first(): Promise<DocumentByInfo<T> | null> {
271
+ return this.fullTableScan().first();
272
+ }
273
+ unique(): Promise<DocumentByInfo<T> | null> {
274
+ return this.fullTableScan().unique();
275
+ }
276
+ [Symbol.asyncIterator](): AsyncIterator<DocumentByInfo<T>, any, undefined> {
277
+ return this.fullTableScan()[Symbol.asyncIterator]();
278
+ }
279
+ }
280
+
281
+ class WrapReader<Ctx, DataModel extends GenericDataModel>
282
+ implements GenericDatabaseReader<DataModel>
283
+ {
284
+ ctx: Ctx;
285
+ db: GenericDatabaseReader<DataModel>;
286
+ rules: Rules<Ctx, DataModel>;
287
+
288
+ constructor(
289
+ ctx: Ctx,
290
+ db: GenericDatabaseReader<DataModel>,
291
+ rules: Rules<Ctx, DataModel>
292
+ ) {
293
+ this.ctx = ctx;
294
+ this.db = db;
295
+ this.rules = rules;
296
+ }
297
+
298
+ normalizeId<TableName extends TableNamesInDataModel<DataModel>>(
299
+ tableName: TableName,
300
+ id: string
301
+ ): GenericId<TableName> | null {
302
+ return this.db.normalizeId(tableName, id);
303
+ }
304
+
305
+ tableName<TableName extends string>(
306
+ id: GenericId<TableName>
307
+ ): TableName | null {
308
+ for (const tableName of Object.keys(this.rules)) {
309
+ if (this.db.normalizeId(tableName, id)) {
310
+ return tableName as TableName;
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
316
+ async predicate<T extends GenericTableInfo>(
317
+ tableName: string,
318
+ doc: DocumentByInfo<T>
319
+ ): Promise<boolean> {
320
+ if (!this.rules[tableName]?.read) {
321
+ return true;
322
+ }
323
+ return await this.rules[tableName]!.read!(this.ctx, doc);
324
+ }
325
+
326
+ async get<TableName extends string>(id: GenericId<TableName>): Promise<any> {
327
+ const doc = await this.db.get(id);
328
+ if (doc) {
329
+ const tableName = this.tableName(id);
330
+ if (tableName && !(await this.predicate(tableName, doc))) {
331
+ return null;
332
+ }
333
+ return doc;
334
+ }
335
+ return null;
336
+ }
337
+
338
+ query<TableName extends string>(
339
+ tableName: TableName
340
+ ): QueryInitializer<NamedTableInfo<DataModel, TableName>> {
341
+ return new WrapQueryInitializer(
342
+ this.db.query(tableName),
343
+ async (d) => await this.predicate(tableName, d)
344
+ );
345
+ }
346
+ }
347
+
348
+ class WrapWriter<Ctx, DataModel extends GenericDataModel>
349
+ implements GenericDatabaseWriter<DataModel>
350
+ {
351
+ ctx: Ctx;
352
+ db: GenericDatabaseWriter<DataModel>;
353
+ reader: GenericDatabaseReader<DataModel>;
354
+ rules: Rules<Ctx, DataModel>;
355
+
356
+ async modifyPredicate<T extends GenericTableInfo>(
357
+ tableName: string,
358
+ doc: DocumentByInfo<T>
359
+ ): Promise<boolean> {
360
+ if (!this.rules[tableName]?.modify) {
361
+ return true;
362
+ }
363
+ return await this.rules[tableName]!.modify!(this.ctx, doc);
364
+ }
365
+
366
+ constructor(
367
+ ctx: Ctx,
368
+ db: GenericDatabaseWriter<DataModel>,
369
+ rules: Rules<Ctx, DataModel>
370
+ ) {
371
+ this.ctx = ctx;
372
+ this.db = db;
373
+ this.reader = new WrapReader(ctx, db, rules);
374
+ this.rules = rules;
375
+ }
376
+ normalizeId<TableName extends TableNamesInDataModel<DataModel>>(
377
+ tableName: TableName,
378
+ id: string
379
+ ): GenericId<TableName> | null {
380
+ return this.db.normalizeId(tableName, id);
381
+ }
382
+ async insert<TableName extends string>(
383
+ table: TableName,
384
+ value: any
385
+ ): Promise<any> {
386
+ const rules = this.rules[table];
387
+ if (rules?.insert && !(await rules.insert(this.ctx, value))) {
388
+ throw new Error("insert access not allowed");
389
+ }
390
+ return await this.db.insert(table, value);
391
+ }
392
+ tableName<TableName extends string>(
393
+ id: GenericId<TableName>
394
+ ): TableName | null {
395
+ for (const tableName of Object.keys(this.rules)) {
396
+ if (this.db.normalizeId(tableName, id)) {
397
+ return tableName as TableName;
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+ async checkAuth<TableName extends string>(id: GenericId<TableName>) {
403
+ // Note all writes already do a `db.get` internally, so this isn't
404
+ // an extra read; it's just populating the cache earlier.
405
+ // Since we call `this.get`, read access controls apply and this may return
406
+ // null even if the document exists.
407
+ const doc = await this.get(id);
408
+ if (doc === null) {
409
+ throw new Error("no read access or doc does not exist");
410
+ }
411
+ const tableName = this.tableName(id);
412
+ if (tableName === null) {
413
+ return;
414
+ }
415
+ if (!(await this.modifyPredicate(tableName, doc))) {
416
+ throw new Error("write access not allowed");
417
+ }
418
+ }
419
+ async patch<TableName extends string>(
420
+ id: GenericId<TableName>,
421
+ value: Partial<any>
422
+ ): Promise<void> {
423
+ await this.checkAuth(id);
424
+ return await this.db.patch(id, value);
425
+ }
426
+ async replace<TableName extends string>(
427
+ id: GenericId<TableName>,
428
+ value: any
429
+ ): Promise<void> {
430
+ await this.checkAuth(id);
431
+ return await this.db.replace(id, value);
432
+ }
433
+ async delete(id: GenericId<string>): Promise<void> {
434
+ await this.checkAuth(id);
435
+ return await this.db.delete(id);
436
+ }
437
+ get<TableName extends string>(id: GenericId<TableName>): Promise<any> {
438
+ return this.reader.get(id);
439
+ }
440
+ query<TableName extends string>(tableName: TableName): QueryInitializer<any> {
441
+ return this.reader.query(tableName);
442
+ }
443
+ }