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.
- package/cjs/package.json +1 -0
- package/cjs/server/relationships.js +81 -0
- package/cjs/server/rowLevelSecurity.js +273 -0
- package/esm/server/relationships.js +41 -0
- package/esm/server/rowLevelSecurity.js +247 -0
- package/package.json +47 -0
- package/server/relationships.ts +272 -0
- package/server/rowLevelSecurity.ts +443 -0
package/cjs/package.json
ADDED
|
@@ -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
|
+
}
|