@genoacms/adapter-gcp 0.10.0 → 0.11.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.
@@ -1,4 +1,4 @@
1
- import type { Adapter } from '@genoacms/contracts/database';
1
+ import { type Adapter } from '@genoacms/contracts/database';
2
2
  import type { GcpDatabaseOptions } from './descriptor.js';
3
3
  /** One Firestore provider, with its own client and credential. */
4
4
  declare const _default: import("@genoacms/contracts").AdapterRuntime<GcpDatabaseOptions, Adapter>;
@@ -1,5 +1,117 @@
1
+ import { DocumentNotFoundError, UnsupportedQueryError } from '@genoacms/contracts/database';
2
+ import { checkQuery, checkValues, decodeToken, encodeToken, matches } from '@genoacms/contracts/database/query';
1
3
  import { defineRuntime } from '@genoacms/contracts';
2
- import { Firestore } from '@google-cloud/firestore';
4
+ import { FieldPath, Firestore } from '@google-cloud/firestore';
5
+ const MAXIMUM_ID_BYTES = 1500;
6
+ const FAILED_PRECONDITION = 9;
7
+ const NOT_FOUND = 5;
8
+ // DATA-2
9
+ const asJson = (data) => JSON.parse(JSON.stringify(data));
10
+ // DATA-3
11
+ const withKey = (collection, id, data) => {
12
+ const keyed = { ...data, [collection.primaryKey.key]: id };
13
+ return keyed;
14
+ };
15
+ const snapshotOf = (collection, document) => ({ reference: { collection, id: document.id }, data: withKey(collection, document.id, document.data()) });
16
+ const errorCode = (error) => error?.code;
17
+ const isInequality = ({ operator }) => operator !== '==';
18
+ // DATA-5
19
+ const isStorableId = (id) => id !== '' && !id.includes('/') && id !== '.' && id !== '..' && !/^__.*__$/.test(id) && Buffer.byteLength(id) <= MAXIMUM_ID_BYTES;
20
+ // DATA-9
21
+ const isUnstorableKeyCondition = (collection, { field, value }) => field === collection.primaryKey.key && typeof value === 'string' && !isStorableId(value);
22
+ // DATA-9
23
+ const matchesNothing = (collection, conditions) => conditions.some(condition => {
24
+ const { field, operator, value } = condition;
25
+ return (field === collection.primaryKey.key && typeof value !== 'string') ||
26
+ (value === null && operator !== '==') ||
27
+ (isUnstorableKeyCondition(collection, condition) && operator === '==');
28
+ });
29
+ // DATA-9
30
+ const withoutVacuousConditions = (collection, conditions) => conditions.filter(condition => !(isUnstorableKeyCondition(collection, condition) && condition.operator === '!='));
31
+ // DATA-9
32
+ const sentToStore = (collection, conditions) => conditions.filter(condition => !isUnstorableKeyCondition(collection, condition));
33
+ const fieldPathOf = (collection, field) => field === collection.primaryKey.key ? FieldPath.documentId() : new FieldPath(field);
34
+ // DB-5
35
+ const inequalityFields = (collection, conditions) => [...new Set(conditions.filter(isInequality).map(({ field }) => field))]
36
+ .filter(field => field !== collection.primaryKey.key)
37
+ .sort();
38
+ const filteredQuery = (query, collection, conditions) => conditions.reduce((filtered, { field, operator, value }) => filtered.where(fieldPathOf(collection, field), operator, value), query);
39
+ const orderedQuery = (query, fields) => [...fields.map(field => new FieldPath(field)), FieldPath.documentId()].reduce((ordered, field) => ordered.orderBy(field), query);
40
+ const cursorAfter = (fields, document, collection) => {
41
+ const data = withKey(collection, document.id, document.data());
42
+ return { values: fields.map(field => data[field]), id: document.id };
43
+ };
44
+ const batchQuery = ({ base, limit, fields }, token) => {
45
+ const resumed = token === undefined ? base : base.startAfter(...cursorValues(token, fields));
46
+ return limit === undefined ? resumed : resumed.limit(limit);
47
+ };
48
+ async function fetchBatch(query) {
49
+ const fetched = [];
50
+ const result = await query.get().catch(refuseUnservableQuery);
51
+ result.forEach(document => { fetched.push(document); });
52
+ return fetched;
53
+ }
54
+ // DATA-11
55
+ function refuseUnservableQuery(error) {
56
+ if (errorCode(error) === FAILED_PRECONDITION && /index/i.test(error.message))
57
+ throw new UnsupportedQueryError(error.message);
58
+ throw error;
59
+ }
60
+ // DATA-8
61
+ function cursorValues(token, fields) {
62
+ const cursor = decodeToken(token);
63
+ if (typeof cursor?.id !== 'string' || !Array.isArray(cursor.values) || cursor.values.length !== fields.length)
64
+ throw new Error('database/invalid-query: unknown page token');
65
+ return [...cursor.values, cursor.id];
66
+ }
67
+ // DATA-10
68
+ function pageAtLimit(scan, batch, documents) {
69
+ for (const [index, document] of batch.entries()) {
70
+ const snapshot = snapshotOf(scan.collection, document);
71
+ if (!matches(snapshot.data, scan.conditions))
72
+ continue;
73
+ documents.push(snapshot);
74
+ if (documents.length !== scan.limit)
75
+ continue;
76
+ const queryMayHaveMore = index < batch.length - 1 || batch.length === scan.limit;
77
+ return queryMayHaveMore ? { documents, next: encodeToken(cursorAfter(scan.fields, document, scan.collection)) } : { documents };
78
+ }
79
+ return undefined;
80
+ }
81
+ // DATA-10
82
+ async function pageOf(scan, firstToken) {
83
+ const documents = [];
84
+ let token = firstToken;
85
+ for (;;) {
86
+ const batch = await fetchBatch(batchQuery(scan, token));
87
+ const page = pageAtLimit(scan, batch, documents);
88
+ if (page !== undefined)
89
+ return page;
90
+ if (scan.limit === undefined || batch.length < scan.limit)
91
+ return { documents };
92
+ token = encodeToken(cursorAfter(scan.fields, batch[batch.length - 1], scan.collection));
93
+ }
94
+ }
95
+ async function requireExisting(document, reference) {
96
+ if (!(await document.get()).exists)
97
+ throw new DocumentNotFoundError(reference);
98
+ }
99
+ // DATA-3
100
+ function withoutKeyField(collection, data) {
101
+ const { [collection.primaryKey.key]: _key, ...rest } = data;
102
+ return rest;
103
+ }
104
+ // DATA-3
105
+ function withoutIgnoredKey(reference, data) {
106
+ const key = reference.collection.primaryKey.key;
107
+ if (data[key] !== undefined && data[key] !== reference.id)
108
+ throw new Error(`database/key-immutable: ${key}`);
109
+ return withoutKeyField(reference.collection, data);
110
+ }
111
+ // DATA-6
112
+ const updateArguments = (fields) => Object.entries(fields)
113
+ .filter(([, value]) => value !== undefined)
114
+ .flatMap(([field, value]) => [new FieldPath(field), asJson(value)]);
3
115
  /** One Firestore provider, with its own client and credential. */
4
116
  export default defineRuntime({
5
117
  create({ projectId, databaseId, credentials }) {
@@ -9,52 +121,65 @@ export default defineRuntime({
9
121
  ...(credentials === undefined ? {} : { credentials })
10
122
  });
11
123
  const createDocument = async (reference, data) => {
12
- const document = await firestore.collection(reference.name).add(data);
124
+ const given = withoutKeyField(reference, data);
125
+ checkValues(given);
126
+ const document = firestore.collection(reference.name).doc();
127
+ const stored = withKey(reference, document.id, given);
128
+ await document.set(asJson(stored));
13
129
  const documentReference = {
14
130
  collection: reference,
15
131
  id: document.id
16
132
  };
17
133
  return {
18
134
  reference: documentReference,
19
- data
135
+ data: stored
20
136
  };
21
137
  };
22
- const getCollection = async (reference) => {
23
- const collection = await firestore.collection(reference.name).get();
24
- const documents = [];
25
- collection.forEach(document => {
26
- documents.push({
27
- reference: {
28
- collection: reference,
29
- id: document.id
30
- },
31
- data: document.data()
32
- });
33
- });
34
- return documents;
138
+ const getCollection = async (reference, params = {}) => {
139
+ checkQuery(params);
140
+ const conditions = withoutVacuousConditions(reference, params.where ?? []);
141
+ const queried = sentToStore(reference, conditions);
142
+ const fields = inequalityFields(reference, queried);
143
+ const firstToken = params.pageToken;
144
+ if (firstToken !== undefined)
145
+ cursorValues(firstToken, fields);
146
+ if (matchesNothing(reference, conditions))
147
+ return { documents: [] };
148
+ const base = orderedQuery(filteredQuery(firestore.collection(reference.name), reference, queried), fields);
149
+ return await pageOf({ base, collection: reference, conditions, fields, limit: params.limit }, firstToken);
35
150
  };
36
151
  const getDocument = async ({ collection, id }) => {
152
+ if (!isStorableId(id))
153
+ return undefined;
37
154
  const document = await firestore.collection(collection.name).doc(id).get();
38
155
  if (!document.exists)
39
156
  return undefined;
40
- const documentReference = {
41
- collection,
42
- id
43
- };
44
- const documentSnapshot = {
45
- reference: documentReference,
46
- data: document.data()
47
- };
48
- return documentSnapshot;
157
+ return { reference: { collection, id }, data: withKey(collection, id, document.data()) };
49
158
  };
50
- const updateDocument = async (reference, document) => {
51
- await firestore.collection(reference.collection.name).doc(reference.id).update(document);
52
- return {
53
- reference,
54
- data: document
55
- };
159
+ const updateDocument = async (reference, data) => {
160
+ const given = withoutIgnoredKey(reference, data);
161
+ checkValues(given);
162
+ if (!isStorableId(reference.id))
163
+ throw new DocumentNotFoundError(reference);
164
+ const [field, value, ...rest] = updateArguments(given);
165
+ const document = firestore.collection(reference.collection.name).doc(reference.id);
166
+ try {
167
+ if (field === undefined)
168
+ await requireExisting(document, reference);
169
+ else
170
+ await document.update(field, value, ...rest);
171
+ }
172
+ catch (error) {
173
+ if (errorCode(error) === NOT_FOUND)
174
+ throw new DocumentNotFoundError(reference);
175
+ throw error;
176
+ }
177
+ const snapshot = { reference, data };
178
+ return snapshot;
56
179
  };
57
180
  const deleteDocument = async (reference) => {
181
+ if (!isStorableId(reference.id))
182
+ return;
58
183
  await firestore.collection(reference.collection.name).doc(reference.id).delete();
59
184
  };
60
185
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genoacms/adapter-gcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Implementation of abstraction layer of GenoaCMS for GCP",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  "@google-cloud/storage": "^7.7.0",
28
28
  "archiver": "^7.0.0",
29
29
  "google-auth-library": "^9.15.1",
30
- "@genoacms/contracts": "^0.0.3",
30
+ "@genoacms/contracts": "^0.1.0",
31
31
  "@genoacms/sveltekit-adapter-cloud-run-functions": "^1.0.1"
32
32
  },
33
33
  "devDependencies": {
@@ -41,7 +41,7 @@
41
41
  "rimraf": "^5.0.5",
42
42
  "typescript": "^5.2.2",
43
43
  "vitest": "^3.2.7",
44
- "@genoacms/conformance": "^0.0.3"
44
+ "@genoacms/conformance": "^0.1.0"
45
45
  },
46
46
  "files": [
47
47
  "dist"