@flarequery/firebase 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +564 -0
- package/dist/index.d.cts +53 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +525 -0
- package/package.json +34 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
createFunction: () => createFunction,
|
|
34
|
+
createOnRequest: () => createOnRequest,
|
|
35
|
+
createServerlessApp: () => createServerlessApp
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(index_exports);
|
|
38
|
+
|
|
39
|
+
// ../core/src/parser/index.ts
|
|
40
|
+
var ParseError = class extends Error {
|
|
41
|
+
constructor(message, position) {
|
|
42
|
+
super(`Parse error at ${position}: ${message}`);
|
|
43
|
+
this.position = position;
|
|
44
|
+
this.name = "ParseError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
function tokenize(input) {
|
|
48
|
+
const tokens = [];
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < input.length) {
|
|
51
|
+
if (/\s/.test(input[i]) || input[i] === ",") {
|
|
52
|
+
i++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (input[i] === "#") {
|
|
56
|
+
while (i < input.length && input[i] !== "\n") i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (input[i] === "{") {
|
|
60
|
+
tokens.push({ kind: "lbrace", value: "{", position: i });
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (input[i] === "}") {
|
|
65
|
+
tokens.push({ kind: "rbrace", value: "}", position: i });
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (input[i] === "(") {
|
|
70
|
+
tokens.push({ kind: "lparen", value: "(", position: i });
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (input[i] === ")") {
|
|
75
|
+
tokens.push({ kind: "rparen", value: ")", position: i });
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (input[i] === ":") {
|
|
80
|
+
tokens.push({ kind: "colon", value: ":", position: i });
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (input[i] === '"') {
|
|
85
|
+
const start = i;
|
|
86
|
+
i++;
|
|
87
|
+
let str = "";
|
|
88
|
+
while (i < input.length && input[i] !== '"') {
|
|
89
|
+
str += input[i];
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
if (i >= input.length) throw new ParseError("unterminated string literal", start);
|
|
93
|
+
i++;
|
|
94
|
+
tokens.push({ kind: "string", value: str, position: start });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (/[a-zA-Z_]/.test(input[i])) {
|
|
98
|
+
const start = i;
|
|
99
|
+
let word = "";
|
|
100
|
+
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
|
101
|
+
word += input[i];
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
tokens.push({ kind: "word", value: word, position: start });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
throw new ParseError(`unexpected character '${input[i]}'`, i);
|
|
108
|
+
}
|
|
109
|
+
tokens.push({ kind: "eof", value: "", position: i });
|
|
110
|
+
return tokens;
|
|
111
|
+
}
|
|
112
|
+
var Parser = class {
|
|
113
|
+
constructor(tokens) {
|
|
114
|
+
this.tokens = tokens;
|
|
115
|
+
this.pos = 0;
|
|
116
|
+
}
|
|
117
|
+
peek() {
|
|
118
|
+
return this.tokens[this.pos];
|
|
119
|
+
}
|
|
120
|
+
consume(expectedKind) {
|
|
121
|
+
const token = this.tokens[this.pos];
|
|
122
|
+
if (expectedKind && token.kind !== expectedKind) {
|
|
123
|
+
throw new ParseError(`expected ${expectedKind}, got ${token.kind}`, token.position);
|
|
124
|
+
}
|
|
125
|
+
this.pos++;
|
|
126
|
+
return token;
|
|
127
|
+
}
|
|
128
|
+
consumeWord(expectedVal) {
|
|
129
|
+
const token = this.consume("word");
|
|
130
|
+
if (expectedVal !== void 0 && token.value !== expectedVal) {
|
|
131
|
+
throw new ParseError(`expected ${expectedVal}, got ${token.value}`, token.position);
|
|
132
|
+
}
|
|
133
|
+
return token;
|
|
134
|
+
}
|
|
135
|
+
parseQuery() {
|
|
136
|
+
if (this.peek().kind === "word" && this.peek().value === "query") {
|
|
137
|
+
this.consume("word");
|
|
138
|
+
}
|
|
139
|
+
this.consume("lbrace");
|
|
140
|
+
const node = this.parseModelSelection();
|
|
141
|
+
this.consume("rbrace");
|
|
142
|
+
if (this.peek().kind !== "eof") {
|
|
143
|
+
throw new ParseError("expected end of query", this.peek().position);
|
|
144
|
+
}
|
|
145
|
+
return node;
|
|
146
|
+
}
|
|
147
|
+
parseModelSelection() {
|
|
148
|
+
const modelToken = this.consumeWord();
|
|
149
|
+
const model = modelToken.value;
|
|
150
|
+
this.consume("lparen");
|
|
151
|
+
this.consumeWord("id");
|
|
152
|
+
this.consume("colon");
|
|
153
|
+
const idToken = this.consume("string");
|
|
154
|
+
this.consume("rparen");
|
|
155
|
+
this.consume("lbrace");
|
|
156
|
+
const selections = this.parseFieldList();
|
|
157
|
+
this.consume("rbrace");
|
|
158
|
+
return { model, id: idToken.value, selections };
|
|
159
|
+
}
|
|
160
|
+
parseFieldList() {
|
|
161
|
+
const fields = [];
|
|
162
|
+
while (this.peek().kind !== "rbrace" && this.peek().kind !== "eof") {
|
|
163
|
+
fields.push(this.parseField());
|
|
164
|
+
}
|
|
165
|
+
if (fields.length === 0) {
|
|
166
|
+
throw new ParseError("selection set cannot be empty", this.peek().position);
|
|
167
|
+
}
|
|
168
|
+
return fields;
|
|
169
|
+
}
|
|
170
|
+
parseField() {
|
|
171
|
+
const nameToken = this.consumeWord();
|
|
172
|
+
const name = nameToken.value;
|
|
173
|
+
if (this.peek().kind === "lbrace") {
|
|
174
|
+
this.consume("lbrace");
|
|
175
|
+
const children = this.parseFieldList();
|
|
176
|
+
this.consume("rbrace");
|
|
177
|
+
return { name, children };
|
|
178
|
+
}
|
|
179
|
+
return { name, children: [] };
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
function parseQuery(input) {
|
|
183
|
+
const token = tokenize(input);
|
|
184
|
+
const parse = new Parser(token);
|
|
185
|
+
return parse.parseQuery();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ../core/src/planner/index.ts
|
|
189
|
+
var PlanError = class extends Error {
|
|
190
|
+
constructor(message) {
|
|
191
|
+
super(`PlanError: ${message}`);
|
|
192
|
+
this.name = "PlanError";
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
function isRelationField(def) {
|
|
196
|
+
return typeof def === "object" && "relation" in def;
|
|
197
|
+
}
|
|
198
|
+
function resolveSelections(selections, model, models, ctx, allOps) {
|
|
199
|
+
const scalarMask = [];
|
|
200
|
+
const dependents = [];
|
|
201
|
+
for (const selection of selections) {
|
|
202
|
+
const fieldDef = model.fields[selection.name];
|
|
203
|
+
if (fieldDef === void 0) {
|
|
204
|
+
throw new PlanError(
|
|
205
|
+
`field '${selection.name}' does not exist on model '${model.source.path}'`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (isRelationField(fieldDef)) {
|
|
209
|
+
if (selection.children.length === 0) {
|
|
210
|
+
throw new PlanError(
|
|
211
|
+
`relation field '${selection.name}' must have a selection set`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const dependent = planRelationField(
|
|
215
|
+
selection,
|
|
216
|
+
fieldDef,
|
|
217
|
+
models,
|
|
218
|
+
ctx,
|
|
219
|
+
allOps
|
|
220
|
+
);
|
|
221
|
+
dependents.push(dependent);
|
|
222
|
+
} else {
|
|
223
|
+
scalarMask.push(selection.name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { scalarMask, dependents };
|
|
227
|
+
}
|
|
228
|
+
function planRelationField(selection, fieldDef, models, ctx, allOps) {
|
|
229
|
+
const { relation, select: allowedSelects } = fieldDef;
|
|
230
|
+
const targetModel = models.get(relation.to);
|
|
231
|
+
if (targetModel === void 0) {
|
|
232
|
+
throw new PlanError(`relation target model '${relation.to}' is not registered`);
|
|
233
|
+
}
|
|
234
|
+
if (allowedSelects !== void 0) {
|
|
235
|
+
for (const child of selection.children) {
|
|
236
|
+
if (!allowedSelects.includes(child.name)) {
|
|
237
|
+
throw new PlanError(
|
|
238
|
+
`field '${child.name}' is not selectable through relation '${selection.name}'. allowed: [${allowedSelects.join(", ")}]`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (targetModel.auth !== void 0 && !targetModel.auth(ctx)) {
|
|
244
|
+
throw new PlanError(`unauthorized access to relation target '${relation.to}'`);
|
|
245
|
+
}
|
|
246
|
+
const { scalarMask, dependents: nestedDependents } = resolveSelections(
|
|
247
|
+
selection.children,
|
|
248
|
+
targetModel,
|
|
249
|
+
models,
|
|
250
|
+
ctx,
|
|
251
|
+
allOps
|
|
252
|
+
);
|
|
253
|
+
const nestedRelationKeys = nestedDependents.map((d) => d.foreignKey);
|
|
254
|
+
const fieldMask = Array.from(/* @__PURE__ */ new Set([...scalarMask, ...nestedRelationKeys]));
|
|
255
|
+
let op;
|
|
256
|
+
if (relation.type === "one") {
|
|
257
|
+
const getOneOp = {
|
|
258
|
+
kind: "getOne",
|
|
259
|
+
collection: targetModel.source.path,
|
|
260
|
+
id: `$ref:${relation.from}`,
|
|
261
|
+
fieldMask,
|
|
262
|
+
dependents: nestedDependents
|
|
263
|
+
};
|
|
264
|
+
op = getOneOp;
|
|
265
|
+
} else {
|
|
266
|
+
const getManyOp = {
|
|
267
|
+
kind: "getMany",
|
|
268
|
+
collection: targetModel.source.path,
|
|
269
|
+
idsFrom: relation.from,
|
|
270
|
+
fieldMask,
|
|
271
|
+
dependents: nestedDependents
|
|
272
|
+
};
|
|
273
|
+
op = getManyOp;
|
|
274
|
+
}
|
|
275
|
+
allOps.push(op);
|
|
276
|
+
return {
|
|
277
|
+
fieldName: selection.name,
|
|
278
|
+
foreignKey: relation.from,
|
|
279
|
+
relationType: relation.type,
|
|
280
|
+
op
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function planModelSelection(modelName, id, selections, models, ctx, allOps) {
|
|
284
|
+
const model = models.get(modelName);
|
|
285
|
+
if (model === void 0) {
|
|
286
|
+
throw new PlanError(`model '${modelName}' is not registered`);
|
|
287
|
+
}
|
|
288
|
+
if (model.auth !== void 0 && !model.auth(ctx)) {
|
|
289
|
+
throw new PlanError(`unauthorized access to model '${modelName}'`);
|
|
290
|
+
}
|
|
291
|
+
const { scalarMask, dependents } = resolveSelections(
|
|
292
|
+
selections,
|
|
293
|
+
model,
|
|
294
|
+
models,
|
|
295
|
+
ctx,
|
|
296
|
+
allOps
|
|
297
|
+
);
|
|
298
|
+
const relationForeignKeys = dependents.map((d) => d.foreignKey);
|
|
299
|
+
const fieldMask = Array.from(/* @__PURE__ */ new Set([...scalarMask, ...relationForeignKeys]));
|
|
300
|
+
const op = {
|
|
301
|
+
kind: "getOne",
|
|
302
|
+
collection: model.source.path,
|
|
303
|
+
id,
|
|
304
|
+
fieldMask,
|
|
305
|
+
dependents
|
|
306
|
+
};
|
|
307
|
+
allOps.push(op);
|
|
308
|
+
return op;
|
|
309
|
+
}
|
|
310
|
+
function buildExecutionPlan(queryNode, models, ctx) {
|
|
311
|
+
const allOps = [];
|
|
312
|
+
const rootOp = planModelSelection(
|
|
313
|
+
queryNode.model,
|
|
314
|
+
queryNode.id,
|
|
315
|
+
queryNode.selections,
|
|
316
|
+
models,
|
|
317
|
+
ctx,
|
|
318
|
+
allOps
|
|
319
|
+
);
|
|
320
|
+
return { root: rootOp, ops: allOps };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ../core/src/executor/index.ts
|
|
324
|
+
var ExecutionError = class extends Error {
|
|
325
|
+
constructor(message) {
|
|
326
|
+
super(`ExecutionError: ${message}`);
|
|
327
|
+
this.name = "ExecutionError";
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
async function resolveDependents(dependents, docData, adapter) {
|
|
331
|
+
if (dependents.length === 0) return {};
|
|
332
|
+
const resolved = await Promise.all(
|
|
333
|
+
dependents.map(async (dependent) => {
|
|
334
|
+
const value = await executeOp(dependent.op, docData, adapter);
|
|
335
|
+
return { fieldName: dependent.fieldName, value };
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
const result = {};
|
|
339
|
+
for (const { fieldName, value } of resolved) {
|
|
340
|
+
result[fieldName] = value;
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
function resolveId(id, parentDoc) {
|
|
345
|
+
if (!id.startsWith("$ref:")) return id;
|
|
346
|
+
const fieldName = id.slice("$ref:".length);
|
|
347
|
+
if (parentDoc === null) {
|
|
348
|
+
throw new ExecutionError(
|
|
349
|
+
`cannot resolve runtime ref '${id}' \u2014 no parent document available`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const value = parentDoc[fieldName];
|
|
353
|
+
if (typeof value !== "string") return null;
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
async function executeGetOne(op, parentDoc, adapter) {
|
|
357
|
+
const id = resolveId(op.id, parentDoc);
|
|
358
|
+
if (id === null) return null;
|
|
359
|
+
const snapshot = await adapter.getOne(op.collection, id, op.fieldMask);
|
|
360
|
+
if (!snapshot.exists) return null;
|
|
361
|
+
const docData = snapshot.data();
|
|
362
|
+
if (docData === void 0) return null;
|
|
363
|
+
const result = await resolveDependents(op.dependents, docData, adapter);
|
|
364
|
+
const foreignKeys = new Set(op.dependents.map((d) => d.foreignKey));
|
|
365
|
+
for (const [key, value] of Object.entries(docData)) {
|
|
366
|
+
if (!foreignKeys.has(key) && op.fieldMask.includes(key)) {
|
|
367
|
+
result[key] = value;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
async function executeGetMany(op, parentDoc, adapter) {
|
|
373
|
+
if (parentDoc === null) {
|
|
374
|
+
throw new ExecutionError(
|
|
375
|
+
`getMany op for collection '${op.collection}' has no parent document`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const rawIds = parentDoc[op.idsFrom];
|
|
379
|
+
if (!Array.isArray(rawIds) || rawIds.length === 0) return null;
|
|
380
|
+
const ids = rawIds.filter((v) => typeof v === "string");
|
|
381
|
+
if (ids.length === 0) return null;
|
|
382
|
+
const snapshots = await adapter.getMany(op.collection, ids, op.fieldMask);
|
|
383
|
+
const results = await Promise.all(
|
|
384
|
+
snapshots.filter((snap) => snap.exists).map(async (snap) => {
|
|
385
|
+
const docData = snap.data();
|
|
386
|
+
if (docData === void 0) return null;
|
|
387
|
+
const result = await resolveDependents(op.dependents, docData, adapter);
|
|
388
|
+
const foreignKeys = new Set(op.dependents.map((d) => d.foreignKey));
|
|
389
|
+
for (const [key, value] of Object.entries(docData)) {
|
|
390
|
+
if (!foreignKeys.has(key) && op.fieldMask.includes(key)) {
|
|
391
|
+
result[key] = value;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
return results.filter((r) => r !== null);
|
|
398
|
+
}
|
|
399
|
+
async function executeOp(op, parentDoc, adapter) {
|
|
400
|
+
if (op.kind === "getOne") {
|
|
401
|
+
return executeGetOne(op, parentDoc, adapter);
|
|
402
|
+
}
|
|
403
|
+
return executeGetMany(op, parentDoc, adapter);
|
|
404
|
+
}
|
|
405
|
+
async function executeplan(plan, adapter) {
|
|
406
|
+
try {
|
|
407
|
+
const data = await executeOp(plan.root, null, adapter);
|
|
408
|
+
return { data };
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err instanceof ExecutionError) {
|
|
411
|
+
return { data: null, errors: [err.message] };
|
|
412
|
+
}
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/adapter.ts
|
|
418
|
+
var FIRESTORE_GETALL_LIMIT = 300;
|
|
419
|
+
function createFirestoreAdapter(db) {
|
|
420
|
+
return {
|
|
421
|
+
async getOne(collection, id, fieldMask) {
|
|
422
|
+
const ref = db.collection(collection).doc(id);
|
|
423
|
+
const snapshot = await ref.get();
|
|
424
|
+
return adaptSnapshot(snapshot, fieldMask);
|
|
425
|
+
},
|
|
426
|
+
async getMany(collection, ids, fieldMask) {
|
|
427
|
+
if (ids.length === 0) return [];
|
|
428
|
+
const chunks = chunkArray(ids, FIRESTORE_GETALL_LIMIT);
|
|
429
|
+
const chunkResults = await Promise.all(
|
|
430
|
+
chunks.map((chunk) => {
|
|
431
|
+
const refs = chunk.map((id) => db.collection(collection).doc(id));
|
|
432
|
+
return db.getAll(...refs, { fieldMask });
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
return chunkResults.flat().map((snap) => adaptSnapshot(snap, fieldMask));
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function adaptSnapshot(snap, fieldMask) {
|
|
440
|
+
return {
|
|
441
|
+
id: snap.id,
|
|
442
|
+
exists: snap.exists,
|
|
443
|
+
data() {
|
|
444
|
+
if (!snap.exists) return void 0;
|
|
445
|
+
const raw = snap.data() ?? {};
|
|
446
|
+
const masked = {};
|
|
447
|
+
for (const field of fieldMask) {
|
|
448
|
+
if (field in raw) {
|
|
449
|
+
masked[field] = raw[field];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return masked;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function chunkArray(arr, size) {
|
|
457
|
+
const chunks = [];
|
|
458
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
459
|
+
chunks.push(arr.slice(i, i + size));
|
|
460
|
+
}
|
|
461
|
+
return chunks;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/app.ts
|
|
465
|
+
function createServerlessApp(options) {
|
|
466
|
+
const { firestore, auth: firebaseAuth } = options;
|
|
467
|
+
const models = /* @__PURE__ */ new Map();
|
|
468
|
+
const adapter = createFirestoreAdapter(firestore);
|
|
469
|
+
return {
|
|
470
|
+
model(name, definition) {
|
|
471
|
+
if (models.has(name)) {
|
|
472
|
+
throw new Error(`model '${name}' is already registered`);
|
|
473
|
+
}
|
|
474
|
+
models.set(name, definition);
|
|
475
|
+
},
|
|
476
|
+
async execute(query, ctx) {
|
|
477
|
+
const queryNode = parseQuery(query);
|
|
478
|
+
const plan = buildExecutionPlan(queryNode, models, ctx);
|
|
479
|
+
return executeplan(plan, adapter);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
async function extractContext(authorization, auth) {
|
|
484
|
+
if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
|
|
485
|
+
return { userId: null, token: null };
|
|
486
|
+
}
|
|
487
|
+
const idToken = authorization.slice("Bearer ".length);
|
|
488
|
+
try {
|
|
489
|
+
const decoded = await auth.verifyIdToken(idToken);
|
|
490
|
+
return {
|
|
491
|
+
userId: decoded.uid,
|
|
492
|
+
token: decoded
|
|
493
|
+
};
|
|
494
|
+
} catch {
|
|
495
|
+
return { userId: null, token: null };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/function.ts
|
|
500
|
+
var functionsV1 = __toESM(require("firebase-functions/v1"), 1);
|
|
501
|
+
var import_https = require("firebase-functions/v2/https");
|
|
502
|
+
function createFunction(app, auth, options = {}) {
|
|
503
|
+
return functionsV1.https.onRequest(async (req, res) => {
|
|
504
|
+
if (options.cors) {
|
|
505
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
506
|
+
res.set("Access-Control-Allow-Methods", "POST");
|
|
507
|
+
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
508
|
+
if (req.method === "OPTIONS") {
|
|
509
|
+
res.status(204).send("");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (req.method !== "POST") {
|
|
514
|
+
res.status(405).json({ error: "method not allowed \u2014 use POST" });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const query = extractQuery(req.body);
|
|
518
|
+
if (query === null) {
|
|
519
|
+
res.status(400).json({ error: "request body must contain a 'query' string field" });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const ctx = await extractContext(req.headers.authorization, auth);
|
|
523
|
+
const response = await app.execute(query, ctx);
|
|
524
|
+
res.status(200).json(response);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
function createOnRequest(app, auth, options = {}) {
|
|
528
|
+
return (0, import_https.onRequest)(async (req, res) => {
|
|
529
|
+
if (options.cors) {
|
|
530
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
531
|
+
res.set("Access-Control-Allow-Methods", "POST");
|
|
532
|
+
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
533
|
+
if (req.method === "OPTIONS") {
|
|
534
|
+
res.status(204).send("");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (req.method !== "POST") {
|
|
539
|
+
res.status(405).json({ error: "method not allowed \u2014 use POST" });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const query = extractQuery(req.body);
|
|
543
|
+
if (query === null) {
|
|
544
|
+
res.status(400).json({ error: "request body must contain a 'query' string field" });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const ctx = await extractContext(req.headers.authorization, auth);
|
|
548
|
+
const response = await app.execute(query, ctx);
|
|
549
|
+
res.status(200).json(response);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
function extractQuery(body) {
|
|
553
|
+
if (typeof body === "string" && body.trim().length > 0) return body.trim();
|
|
554
|
+
if (typeof body === "object" && body !== null && "query" in body && typeof body["query"] === "string") {
|
|
555
|
+
return body["query"].trim();
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
560
|
+
0 && (module.exports = {
|
|
561
|
+
createFunction,
|
|
562
|
+
createOnRequest,
|
|
563
|
+
createServerlessApp
|
|
564
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Auth } from 'firebase-admin/auth';
|
|
2
|
+
import { Firestore } from 'firebase-admin/firestore';
|
|
3
|
+
import * as firebase_functions_v2_https from 'firebase-functions/v2/https';
|
|
4
|
+
import * as functionsV1 from 'firebase-functions/v1';
|
|
5
|
+
|
|
6
|
+
type RelationType = "one" | "many";
|
|
7
|
+
interface RelationDefinition {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
type: RelationType;
|
|
11
|
+
}
|
|
12
|
+
type ScalarType = "string" | "number" | "boolean" | "timestamp";
|
|
13
|
+
interface RelationFieldDefinition {
|
|
14
|
+
relation: RelationDefinition;
|
|
15
|
+
select?: string[];
|
|
16
|
+
}
|
|
17
|
+
type FieldDefinition = ScalarType | RelationFieldDefinition;
|
|
18
|
+
interface ModelDefinition {
|
|
19
|
+
source: FirestoreCollectionRef;
|
|
20
|
+
fields: Record<string, FieldDefinition>;
|
|
21
|
+
auth?: AuthRule;
|
|
22
|
+
}
|
|
23
|
+
type AuthRule = (ctx: QueryContext) => boolean;
|
|
24
|
+
interface QueryContext {
|
|
25
|
+
userId: string | null;
|
|
26
|
+
token: Record<string, unknown> | null;
|
|
27
|
+
}
|
|
28
|
+
interface FirestoreCollectionRef {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
}
|
|
31
|
+
type FlareResult = Record<string, unknown>;
|
|
32
|
+
interface FlareResponse {
|
|
33
|
+
data: FlareResult | null;
|
|
34
|
+
errors?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ServerlessAppOptions {
|
|
38
|
+
firestore: Firestore;
|
|
39
|
+
auth: Auth;
|
|
40
|
+
}
|
|
41
|
+
interface ServerlessApp {
|
|
42
|
+
model(name: string, definition: ModelDefinition): void;
|
|
43
|
+
execute(query: string, ctx: QueryContext): Promise<FlareResponse>;
|
|
44
|
+
}
|
|
45
|
+
declare function createServerlessApp(options: ServerlessAppOptions): ServerlessApp;
|
|
46
|
+
|
|
47
|
+
interface FunctionOptions {
|
|
48
|
+
cors?: boolean;
|
|
49
|
+
}
|
|
50
|
+
declare function createFunction(app: ServerlessApp, auth: Auth, options?: FunctionOptions): functionsV1.HttpsFunction;
|
|
51
|
+
declare function createOnRequest(app: ServerlessApp, auth: Auth, options?: FunctionOptions): firebase_functions_v2_https.HttpsFunction;
|
|
52
|
+
|
|
53
|
+
export { type AuthRule, type FieldDefinition, type FlareResponse, type ModelDefinition, type QueryContext, type RelationDefinition, createFunction, createOnRequest, createServerlessApp };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Auth } from 'firebase-admin/auth';
|
|
2
|
+
import { Firestore } from 'firebase-admin/firestore';
|
|
3
|
+
import * as firebase_functions_v2_https from 'firebase-functions/v2/https';
|
|
4
|
+
import * as functionsV1 from 'firebase-functions/v1';
|
|
5
|
+
|
|
6
|
+
type RelationType = "one" | "many";
|
|
7
|
+
interface RelationDefinition {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
type: RelationType;
|
|
11
|
+
}
|
|
12
|
+
type ScalarType = "string" | "number" | "boolean" | "timestamp";
|
|
13
|
+
interface RelationFieldDefinition {
|
|
14
|
+
relation: RelationDefinition;
|
|
15
|
+
select?: string[];
|
|
16
|
+
}
|
|
17
|
+
type FieldDefinition = ScalarType | RelationFieldDefinition;
|
|
18
|
+
interface ModelDefinition {
|
|
19
|
+
source: FirestoreCollectionRef;
|
|
20
|
+
fields: Record<string, FieldDefinition>;
|
|
21
|
+
auth?: AuthRule;
|
|
22
|
+
}
|
|
23
|
+
type AuthRule = (ctx: QueryContext) => boolean;
|
|
24
|
+
interface QueryContext {
|
|
25
|
+
userId: string | null;
|
|
26
|
+
token: Record<string, unknown> | null;
|
|
27
|
+
}
|
|
28
|
+
interface FirestoreCollectionRef {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
}
|
|
31
|
+
type FlareResult = Record<string, unknown>;
|
|
32
|
+
interface FlareResponse {
|
|
33
|
+
data: FlareResult | null;
|
|
34
|
+
errors?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ServerlessAppOptions {
|
|
38
|
+
firestore: Firestore;
|
|
39
|
+
auth: Auth;
|
|
40
|
+
}
|
|
41
|
+
interface ServerlessApp {
|
|
42
|
+
model(name: string, definition: ModelDefinition): void;
|
|
43
|
+
execute(query: string, ctx: QueryContext): Promise<FlareResponse>;
|
|
44
|
+
}
|
|
45
|
+
declare function createServerlessApp(options: ServerlessAppOptions): ServerlessApp;
|
|
46
|
+
|
|
47
|
+
interface FunctionOptions {
|
|
48
|
+
cors?: boolean;
|
|
49
|
+
}
|
|
50
|
+
declare function createFunction(app: ServerlessApp, auth: Auth, options?: FunctionOptions): functionsV1.HttpsFunction;
|
|
51
|
+
declare function createOnRequest(app: ServerlessApp, auth: Auth, options?: FunctionOptions): firebase_functions_v2_https.HttpsFunction;
|
|
52
|
+
|
|
53
|
+
export { type AuthRule, type FieldDefinition, type FlareResponse, type ModelDefinition, type QueryContext, type RelationDefinition, createFunction, createOnRequest, createServerlessApp };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// ../core/src/parser/index.ts
|
|
2
|
+
var ParseError = class extends Error {
|
|
3
|
+
constructor(message, position) {
|
|
4
|
+
super(`Parse error at ${position}: ${message}`);
|
|
5
|
+
this.position = position;
|
|
6
|
+
this.name = "ParseError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function tokenize(input) {
|
|
10
|
+
const tokens = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < input.length) {
|
|
13
|
+
if (/\s/.test(input[i]) || input[i] === ",") {
|
|
14
|
+
i++;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (input[i] === "#") {
|
|
18
|
+
while (i < input.length && input[i] !== "\n") i++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (input[i] === "{") {
|
|
22
|
+
tokens.push({ kind: "lbrace", value: "{", position: i });
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (input[i] === "}") {
|
|
27
|
+
tokens.push({ kind: "rbrace", value: "}", position: i });
|
|
28
|
+
i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (input[i] === "(") {
|
|
32
|
+
tokens.push({ kind: "lparen", value: "(", position: i });
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (input[i] === ")") {
|
|
37
|
+
tokens.push({ kind: "rparen", value: ")", position: i });
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (input[i] === ":") {
|
|
42
|
+
tokens.push({ kind: "colon", value: ":", position: i });
|
|
43
|
+
i++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (input[i] === '"') {
|
|
47
|
+
const start = i;
|
|
48
|
+
i++;
|
|
49
|
+
let str = "";
|
|
50
|
+
while (i < input.length && input[i] !== '"') {
|
|
51
|
+
str += input[i];
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
if (i >= input.length) throw new ParseError("unterminated string literal", start);
|
|
55
|
+
i++;
|
|
56
|
+
tokens.push({ kind: "string", value: str, position: start });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (/[a-zA-Z_]/.test(input[i])) {
|
|
60
|
+
const start = i;
|
|
61
|
+
let word = "";
|
|
62
|
+
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
|
|
63
|
+
word += input[i];
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
tokens.push({ kind: "word", value: word, position: start });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
throw new ParseError(`unexpected character '${input[i]}'`, i);
|
|
70
|
+
}
|
|
71
|
+
tokens.push({ kind: "eof", value: "", position: i });
|
|
72
|
+
return tokens;
|
|
73
|
+
}
|
|
74
|
+
var Parser = class {
|
|
75
|
+
constructor(tokens) {
|
|
76
|
+
this.tokens = tokens;
|
|
77
|
+
this.pos = 0;
|
|
78
|
+
}
|
|
79
|
+
peek() {
|
|
80
|
+
return this.tokens[this.pos];
|
|
81
|
+
}
|
|
82
|
+
consume(expectedKind) {
|
|
83
|
+
const token = this.tokens[this.pos];
|
|
84
|
+
if (expectedKind && token.kind !== expectedKind) {
|
|
85
|
+
throw new ParseError(`expected ${expectedKind}, got ${token.kind}`, token.position);
|
|
86
|
+
}
|
|
87
|
+
this.pos++;
|
|
88
|
+
return token;
|
|
89
|
+
}
|
|
90
|
+
consumeWord(expectedVal) {
|
|
91
|
+
const token = this.consume("word");
|
|
92
|
+
if (expectedVal !== void 0 && token.value !== expectedVal) {
|
|
93
|
+
throw new ParseError(`expected ${expectedVal}, got ${token.value}`, token.position);
|
|
94
|
+
}
|
|
95
|
+
return token;
|
|
96
|
+
}
|
|
97
|
+
parseQuery() {
|
|
98
|
+
if (this.peek().kind === "word" && this.peek().value === "query") {
|
|
99
|
+
this.consume("word");
|
|
100
|
+
}
|
|
101
|
+
this.consume("lbrace");
|
|
102
|
+
const node = this.parseModelSelection();
|
|
103
|
+
this.consume("rbrace");
|
|
104
|
+
if (this.peek().kind !== "eof") {
|
|
105
|
+
throw new ParseError("expected end of query", this.peek().position);
|
|
106
|
+
}
|
|
107
|
+
return node;
|
|
108
|
+
}
|
|
109
|
+
parseModelSelection() {
|
|
110
|
+
const modelToken = this.consumeWord();
|
|
111
|
+
const model = modelToken.value;
|
|
112
|
+
this.consume("lparen");
|
|
113
|
+
this.consumeWord("id");
|
|
114
|
+
this.consume("colon");
|
|
115
|
+
const idToken = this.consume("string");
|
|
116
|
+
this.consume("rparen");
|
|
117
|
+
this.consume("lbrace");
|
|
118
|
+
const selections = this.parseFieldList();
|
|
119
|
+
this.consume("rbrace");
|
|
120
|
+
return { model, id: idToken.value, selections };
|
|
121
|
+
}
|
|
122
|
+
parseFieldList() {
|
|
123
|
+
const fields = [];
|
|
124
|
+
while (this.peek().kind !== "rbrace" && this.peek().kind !== "eof") {
|
|
125
|
+
fields.push(this.parseField());
|
|
126
|
+
}
|
|
127
|
+
if (fields.length === 0) {
|
|
128
|
+
throw new ParseError("selection set cannot be empty", this.peek().position);
|
|
129
|
+
}
|
|
130
|
+
return fields;
|
|
131
|
+
}
|
|
132
|
+
parseField() {
|
|
133
|
+
const nameToken = this.consumeWord();
|
|
134
|
+
const name = nameToken.value;
|
|
135
|
+
if (this.peek().kind === "lbrace") {
|
|
136
|
+
this.consume("lbrace");
|
|
137
|
+
const children = this.parseFieldList();
|
|
138
|
+
this.consume("rbrace");
|
|
139
|
+
return { name, children };
|
|
140
|
+
}
|
|
141
|
+
return { name, children: [] };
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
function parseQuery(input) {
|
|
145
|
+
const token = tokenize(input);
|
|
146
|
+
const parse = new Parser(token);
|
|
147
|
+
return parse.parseQuery();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ../core/src/planner/index.ts
|
|
151
|
+
var PlanError = class extends Error {
|
|
152
|
+
constructor(message) {
|
|
153
|
+
super(`PlanError: ${message}`);
|
|
154
|
+
this.name = "PlanError";
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
function isRelationField(def) {
|
|
158
|
+
return typeof def === "object" && "relation" in def;
|
|
159
|
+
}
|
|
160
|
+
function resolveSelections(selections, model, models, ctx, allOps) {
|
|
161
|
+
const scalarMask = [];
|
|
162
|
+
const dependents = [];
|
|
163
|
+
for (const selection of selections) {
|
|
164
|
+
const fieldDef = model.fields[selection.name];
|
|
165
|
+
if (fieldDef === void 0) {
|
|
166
|
+
throw new PlanError(
|
|
167
|
+
`field '${selection.name}' does not exist on model '${model.source.path}'`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (isRelationField(fieldDef)) {
|
|
171
|
+
if (selection.children.length === 0) {
|
|
172
|
+
throw new PlanError(
|
|
173
|
+
`relation field '${selection.name}' must have a selection set`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const dependent = planRelationField(
|
|
177
|
+
selection,
|
|
178
|
+
fieldDef,
|
|
179
|
+
models,
|
|
180
|
+
ctx,
|
|
181
|
+
allOps
|
|
182
|
+
);
|
|
183
|
+
dependents.push(dependent);
|
|
184
|
+
} else {
|
|
185
|
+
scalarMask.push(selection.name);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { scalarMask, dependents };
|
|
189
|
+
}
|
|
190
|
+
function planRelationField(selection, fieldDef, models, ctx, allOps) {
|
|
191
|
+
const { relation, select: allowedSelects } = fieldDef;
|
|
192
|
+
const targetModel = models.get(relation.to);
|
|
193
|
+
if (targetModel === void 0) {
|
|
194
|
+
throw new PlanError(`relation target model '${relation.to}' is not registered`);
|
|
195
|
+
}
|
|
196
|
+
if (allowedSelects !== void 0) {
|
|
197
|
+
for (const child of selection.children) {
|
|
198
|
+
if (!allowedSelects.includes(child.name)) {
|
|
199
|
+
throw new PlanError(
|
|
200
|
+
`field '${child.name}' is not selectable through relation '${selection.name}'. allowed: [${allowedSelects.join(", ")}]`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (targetModel.auth !== void 0 && !targetModel.auth(ctx)) {
|
|
206
|
+
throw new PlanError(`unauthorized access to relation target '${relation.to}'`);
|
|
207
|
+
}
|
|
208
|
+
const { scalarMask, dependents: nestedDependents } = resolveSelections(
|
|
209
|
+
selection.children,
|
|
210
|
+
targetModel,
|
|
211
|
+
models,
|
|
212
|
+
ctx,
|
|
213
|
+
allOps
|
|
214
|
+
);
|
|
215
|
+
const nestedRelationKeys = nestedDependents.map((d) => d.foreignKey);
|
|
216
|
+
const fieldMask = Array.from(/* @__PURE__ */ new Set([...scalarMask, ...nestedRelationKeys]));
|
|
217
|
+
let op;
|
|
218
|
+
if (relation.type === "one") {
|
|
219
|
+
const getOneOp = {
|
|
220
|
+
kind: "getOne",
|
|
221
|
+
collection: targetModel.source.path,
|
|
222
|
+
id: `$ref:${relation.from}`,
|
|
223
|
+
fieldMask,
|
|
224
|
+
dependents: nestedDependents
|
|
225
|
+
};
|
|
226
|
+
op = getOneOp;
|
|
227
|
+
} else {
|
|
228
|
+
const getManyOp = {
|
|
229
|
+
kind: "getMany",
|
|
230
|
+
collection: targetModel.source.path,
|
|
231
|
+
idsFrom: relation.from,
|
|
232
|
+
fieldMask,
|
|
233
|
+
dependents: nestedDependents
|
|
234
|
+
};
|
|
235
|
+
op = getManyOp;
|
|
236
|
+
}
|
|
237
|
+
allOps.push(op);
|
|
238
|
+
return {
|
|
239
|
+
fieldName: selection.name,
|
|
240
|
+
foreignKey: relation.from,
|
|
241
|
+
relationType: relation.type,
|
|
242
|
+
op
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function planModelSelection(modelName, id, selections, models, ctx, allOps) {
|
|
246
|
+
const model = models.get(modelName);
|
|
247
|
+
if (model === void 0) {
|
|
248
|
+
throw new PlanError(`model '${modelName}' is not registered`);
|
|
249
|
+
}
|
|
250
|
+
if (model.auth !== void 0 && !model.auth(ctx)) {
|
|
251
|
+
throw new PlanError(`unauthorized access to model '${modelName}'`);
|
|
252
|
+
}
|
|
253
|
+
const { scalarMask, dependents } = resolveSelections(
|
|
254
|
+
selections,
|
|
255
|
+
model,
|
|
256
|
+
models,
|
|
257
|
+
ctx,
|
|
258
|
+
allOps
|
|
259
|
+
);
|
|
260
|
+
const relationForeignKeys = dependents.map((d) => d.foreignKey);
|
|
261
|
+
const fieldMask = Array.from(/* @__PURE__ */ new Set([...scalarMask, ...relationForeignKeys]));
|
|
262
|
+
const op = {
|
|
263
|
+
kind: "getOne",
|
|
264
|
+
collection: model.source.path,
|
|
265
|
+
id,
|
|
266
|
+
fieldMask,
|
|
267
|
+
dependents
|
|
268
|
+
};
|
|
269
|
+
allOps.push(op);
|
|
270
|
+
return op;
|
|
271
|
+
}
|
|
272
|
+
function buildExecutionPlan(queryNode, models, ctx) {
|
|
273
|
+
const allOps = [];
|
|
274
|
+
const rootOp = planModelSelection(
|
|
275
|
+
queryNode.model,
|
|
276
|
+
queryNode.id,
|
|
277
|
+
queryNode.selections,
|
|
278
|
+
models,
|
|
279
|
+
ctx,
|
|
280
|
+
allOps
|
|
281
|
+
);
|
|
282
|
+
return { root: rootOp, ops: allOps };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ../core/src/executor/index.ts
|
|
286
|
+
var ExecutionError = class extends Error {
|
|
287
|
+
constructor(message) {
|
|
288
|
+
super(`ExecutionError: ${message}`);
|
|
289
|
+
this.name = "ExecutionError";
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
async function resolveDependents(dependents, docData, adapter) {
|
|
293
|
+
if (dependents.length === 0) return {};
|
|
294
|
+
const resolved = await Promise.all(
|
|
295
|
+
dependents.map(async (dependent) => {
|
|
296
|
+
const value = await executeOp(dependent.op, docData, adapter);
|
|
297
|
+
return { fieldName: dependent.fieldName, value };
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
const result = {};
|
|
301
|
+
for (const { fieldName, value } of resolved) {
|
|
302
|
+
result[fieldName] = value;
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
function resolveId(id, parentDoc) {
|
|
307
|
+
if (!id.startsWith("$ref:")) return id;
|
|
308
|
+
const fieldName = id.slice("$ref:".length);
|
|
309
|
+
if (parentDoc === null) {
|
|
310
|
+
throw new ExecutionError(
|
|
311
|
+
`cannot resolve runtime ref '${id}' \u2014 no parent document available`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const value = parentDoc[fieldName];
|
|
315
|
+
if (typeof value !== "string") return null;
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
async function executeGetOne(op, parentDoc, adapter) {
|
|
319
|
+
const id = resolveId(op.id, parentDoc);
|
|
320
|
+
if (id === null) return null;
|
|
321
|
+
const snapshot = await adapter.getOne(op.collection, id, op.fieldMask);
|
|
322
|
+
if (!snapshot.exists) return null;
|
|
323
|
+
const docData = snapshot.data();
|
|
324
|
+
if (docData === void 0) return null;
|
|
325
|
+
const result = await resolveDependents(op.dependents, docData, adapter);
|
|
326
|
+
const foreignKeys = new Set(op.dependents.map((d) => d.foreignKey));
|
|
327
|
+
for (const [key, value] of Object.entries(docData)) {
|
|
328
|
+
if (!foreignKeys.has(key) && op.fieldMask.includes(key)) {
|
|
329
|
+
result[key] = value;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
async function executeGetMany(op, parentDoc, adapter) {
|
|
335
|
+
if (parentDoc === null) {
|
|
336
|
+
throw new ExecutionError(
|
|
337
|
+
`getMany op for collection '${op.collection}' has no parent document`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const rawIds = parentDoc[op.idsFrom];
|
|
341
|
+
if (!Array.isArray(rawIds) || rawIds.length === 0) return null;
|
|
342
|
+
const ids = rawIds.filter((v) => typeof v === "string");
|
|
343
|
+
if (ids.length === 0) return null;
|
|
344
|
+
const snapshots = await adapter.getMany(op.collection, ids, op.fieldMask);
|
|
345
|
+
const results = await Promise.all(
|
|
346
|
+
snapshots.filter((snap) => snap.exists).map(async (snap) => {
|
|
347
|
+
const docData = snap.data();
|
|
348
|
+
if (docData === void 0) return null;
|
|
349
|
+
const result = await resolveDependents(op.dependents, docData, adapter);
|
|
350
|
+
const foreignKeys = new Set(op.dependents.map((d) => d.foreignKey));
|
|
351
|
+
for (const [key, value] of Object.entries(docData)) {
|
|
352
|
+
if (!foreignKeys.has(key) && op.fieldMask.includes(key)) {
|
|
353
|
+
result[key] = value;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
return results.filter((r) => r !== null);
|
|
360
|
+
}
|
|
361
|
+
async function executeOp(op, parentDoc, adapter) {
|
|
362
|
+
if (op.kind === "getOne") {
|
|
363
|
+
return executeGetOne(op, parentDoc, adapter);
|
|
364
|
+
}
|
|
365
|
+
return executeGetMany(op, parentDoc, adapter);
|
|
366
|
+
}
|
|
367
|
+
async function executeplan(plan, adapter) {
|
|
368
|
+
try {
|
|
369
|
+
const data = await executeOp(plan.root, null, adapter);
|
|
370
|
+
return { data };
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (err instanceof ExecutionError) {
|
|
373
|
+
return { data: null, errors: [err.message] };
|
|
374
|
+
}
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/adapter.ts
|
|
380
|
+
var FIRESTORE_GETALL_LIMIT = 300;
|
|
381
|
+
function createFirestoreAdapter(db) {
|
|
382
|
+
return {
|
|
383
|
+
async getOne(collection, id, fieldMask) {
|
|
384
|
+
const ref = db.collection(collection).doc(id);
|
|
385
|
+
const snapshot = await ref.get();
|
|
386
|
+
return adaptSnapshot(snapshot, fieldMask);
|
|
387
|
+
},
|
|
388
|
+
async getMany(collection, ids, fieldMask) {
|
|
389
|
+
if (ids.length === 0) return [];
|
|
390
|
+
const chunks = chunkArray(ids, FIRESTORE_GETALL_LIMIT);
|
|
391
|
+
const chunkResults = await Promise.all(
|
|
392
|
+
chunks.map((chunk) => {
|
|
393
|
+
const refs = chunk.map((id) => db.collection(collection).doc(id));
|
|
394
|
+
return db.getAll(...refs, { fieldMask });
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
return chunkResults.flat().map((snap) => adaptSnapshot(snap, fieldMask));
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function adaptSnapshot(snap, fieldMask) {
|
|
402
|
+
return {
|
|
403
|
+
id: snap.id,
|
|
404
|
+
exists: snap.exists,
|
|
405
|
+
data() {
|
|
406
|
+
if (!snap.exists) return void 0;
|
|
407
|
+
const raw = snap.data() ?? {};
|
|
408
|
+
const masked = {};
|
|
409
|
+
for (const field of fieldMask) {
|
|
410
|
+
if (field in raw) {
|
|
411
|
+
masked[field] = raw[field];
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return masked;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function chunkArray(arr, size) {
|
|
419
|
+
const chunks = [];
|
|
420
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
421
|
+
chunks.push(arr.slice(i, i + size));
|
|
422
|
+
}
|
|
423
|
+
return chunks;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/app.ts
|
|
427
|
+
function createServerlessApp(options) {
|
|
428
|
+
const { firestore, auth: firebaseAuth } = options;
|
|
429
|
+
const models = /* @__PURE__ */ new Map();
|
|
430
|
+
const adapter = createFirestoreAdapter(firestore);
|
|
431
|
+
return {
|
|
432
|
+
model(name, definition) {
|
|
433
|
+
if (models.has(name)) {
|
|
434
|
+
throw new Error(`model '${name}' is already registered`);
|
|
435
|
+
}
|
|
436
|
+
models.set(name, definition);
|
|
437
|
+
},
|
|
438
|
+
async execute(query, ctx) {
|
|
439
|
+
const queryNode = parseQuery(query);
|
|
440
|
+
const plan = buildExecutionPlan(queryNode, models, ctx);
|
|
441
|
+
return executeplan(plan, adapter);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
async function extractContext(authorization, auth) {
|
|
446
|
+
if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
|
|
447
|
+
return { userId: null, token: null };
|
|
448
|
+
}
|
|
449
|
+
const idToken = authorization.slice("Bearer ".length);
|
|
450
|
+
try {
|
|
451
|
+
const decoded = await auth.verifyIdToken(idToken);
|
|
452
|
+
return {
|
|
453
|
+
userId: decoded.uid,
|
|
454
|
+
token: decoded
|
|
455
|
+
};
|
|
456
|
+
} catch {
|
|
457
|
+
return { userId: null, token: null };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/function.ts
|
|
462
|
+
import * as functionsV1 from "firebase-functions/v1";
|
|
463
|
+
import { onRequest } from "firebase-functions/v2/https";
|
|
464
|
+
function createFunction(app, auth, options = {}) {
|
|
465
|
+
return functionsV1.https.onRequest(async (req, res) => {
|
|
466
|
+
if (options.cors) {
|
|
467
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
468
|
+
res.set("Access-Control-Allow-Methods", "POST");
|
|
469
|
+
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
470
|
+
if (req.method === "OPTIONS") {
|
|
471
|
+
res.status(204).send("");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (req.method !== "POST") {
|
|
476
|
+
res.status(405).json({ error: "method not allowed \u2014 use POST" });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const query = extractQuery(req.body);
|
|
480
|
+
if (query === null) {
|
|
481
|
+
res.status(400).json({ error: "request body must contain a 'query' string field" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const ctx = await extractContext(req.headers.authorization, auth);
|
|
485
|
+
const response = await app.execute(query, ctx);
|
|
486
|
+
res.status(200).json(response);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
function createOnRequest(app, auth, options = {}) {
|
|
490
|
+
return onRequest(async (req, res) => {
|
|
491
|
+
if (options.cors) {
|
|
492
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
493
|
+
res.set("Access-Control-Allow-Methods", "POST");
|
|
494
|
+
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
495
|
+
if (req.method === "OPTIONS") {
|
|
496
|
+
res.status(204).send("");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (req.method !== "POST") {
|
|
501
|
+
res.status(405).json({ error: "method not allowed \u2014 use POST" });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const query = extractQuery(req.body);
|
|
505
|
+
if (query === null) {
|
|
506
|
+
res.status(400).json({ error: "request body must contain a 'query' string field" });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const ctx = await extractContext(req.headers.authorization, auth);
|
|
510
|
+
const response = await app.execute(query, ctx);
|
|
511
|
+
res.status(200).json(response);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function extractQuery(body) {
|
|
515
|
+
if (typeof body === "string" && body.trim().length > 0) return body.trim();
|
|
516
|
+
if (typeof body === "object" && body !== null && "query" in body && typeof body["query"] === "string") {
|
|
517
|
+
return body["query"].trim();
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
export {
|
|
522
|
+
createFunction,
|
|
523
|
+
createOnRequest,
|
|
524
|
+
createServerlessApp
|
|
525
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flarequery/firebase",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --no-dts-resolve",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"firebase-admin": ">=11.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"firebase-functions": "^7.0.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"tsup": "^8.5.1",
|
|
32
|
+
"vitest": "^4.0.18"
|
|
33
|
+
}
|
|
34
|
+
}
|