hikoutei 0.5.0 → 0.5.2

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.
Files changed (73) hide show
  1. package/dist/adapter/sheets/providers/google-sheets-api/GoogleSheetsApiSyncProvider.d.ts +5 -15
  2. package/dist/adapter/sheets/providers/google-sheets-api/GoogleSheetsApiSyncProvider.d.ts.map +1 -1
  3. package/dist/adapter/sheets/providers/google-sheets-api/GoogleSheetsApiSyncProvider.js +33 -945
  4. package/dist/adapter/sheets/providers/google-sheets-api/GoogleSheetsApiSyncProvider.js.map +1 -1
  5. package/dist/adapter/sheets/providers/google-sheets-api/model/planner.d.ts +14 -20
  6. package/dist/adapter/sheets/providers/google-sheets-api/model/planner.d.ts.map +1 -1
  7. package/dist/adapter/sheets/providers/google-sheets-api/model/planner.js +16 -200
  8. package/dist/adapter/sheets/providers/google-sheets-api/model/planner.js.map +1 -1
  9. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerDeletion.d.ts +14 -0
  10. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerDeletion.d.ts.map +1 -0
  11. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerDeletion.js +37 -0
  12. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerDeletion.js.map +1 -0
  13. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerReceipt.d.ts +19 -0
  14. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerReceipt.d.ts.map +1 -0
  15. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerReceipt.js +76 -0
  16. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerReceipt.js.map +1 -0
  17. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerWorkingRow.d.ts +23 -0
  18. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerWorkingRow.d.ts.map +1 -0
  19. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerWorkingRow.js +122 -0
  20. package/dist/adapter/sheets/providers/google-sheets-api/model/plannerWorkingRow.js.map +1 -0
  21. package/dist/adapter/sheets/providers/google-sheets-api/model/preflight.d.ts +15 -222
  22. package/dist/adapter/sheets/providers/google-sheets-api/model/preflight.d.ts.map +1 -1
  23. package/dist/adapter/sheets/providers/google-sheets-api/model/preflight.js +14 -687
  24. package/dist/adapter/sheets/providers/google-sheets-api/model/preflight.js.map +1 -1
  25. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightContext.d.ts +125 -0
  26. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightContext.d.ts.map +1 -0
  27. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightContext.js +161 -0
  28. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightContext.js.map +1 -0
  29. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightFields.d.ts +67 -0
  30. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightFields.d.ts.map +1 -0
  31. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightFields.js +94 -0
  32. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightFields.js.map +1 -0
  33. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightHeaders.d.ts +24 -0
  34. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightHeaders.d.ts.map +1 -0
  35. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightHeaders.js +52 -0
  36. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightHeaders.js.map +1 -0
  37. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightParsing.d.ts +23 -0
  38. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightParsing.d.ts.map +1 -0
  39. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightParsing.js +239 -0
  40. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightParsing.js.map +1 -0
  41. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightRows.d.ts +53 -0
  42. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightRows.d.ts.map +1 -0
  43. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightRows.js +198 -0
  44. package/dist/adapter/sheets/providers/google-sheets-api/model/preflightRows.js.map +1 -0
  45. package/dist/adapter/sheets/providers/google-sheets-api/operations/anchors.d.ts +21 -0
  46. package/dist/adapter/sheets/providers/google-sheets-api/operations/anchors.d.ts.map +1 -0
  47. package/dist/adapter/sheets/providers/google-sheets-api/operations/anchors.js +57 -0
  48. package/dist/adapter/sheets/providers/google-sheets-api/operations/anchors.js.map +1 -0
  49. package/dist/adapter/sheets/providers/google-sheets-api/operations/applyEffects.d.ts +19 -0
  50. package/dist/adapter/sheets/providers/google-sheets-api/operations/applyEffects.d.ts.map +1 -0
  51. package/dist/adapter/sheets/providers/google-sheets-api/operations/applyEffects.js +188 -0
  52. package/dist/adapter/sheets/providers/google-sheets-api/operations/applyEffects.js.map +1 -0
  53. package/dist/adapter/sheets/providers/google-sheets-api/operations/fastAppend.d.ts +13 -0
  54. package/dist/adapter/sheets/providers/google-sheets-api/operations/fastAppend.d.ts.map +1 -0
  55. package/dist/adapter/sheets/providers/google-sheets-api/operations/fastAppend.js +229 -0
  56. package/dist/adapter/sheets/providers/google-sheets-api/operations/fastAppend.js.map +1 -0
  57. package/dist/adapter/sheets/providers/google-sheets-api/operations/preflightOp.d.ts +27 -0
  58. package/dist/adapter/sheets/providers/google-sheets-api/operations/preflightOp.d.ts.map +1 -0
  59. package/dist/adapter/sheets/providers/google-sheets-api/operations/preflightOp.js +54 -0
  60. package/dist/adapter/sheets/providers/google-sheets-api/operations/preflightOp.js.map +1 -0
  61. package/dist/adapter/sheets/providers/google-sheets-api/operations/provisioning.d.ts +28 -0
  62. package/dist/adapter/sheets/providers/google-sheets-api/operations/provisioning.d.ts.map +1 -0
  63. package/dist/adapter/sheets/providers/google-sheets-api/operations/provisioning.js +256 -0
  64. package/dist/adapter/sheets/providers/google-sheets-api/operations/provisioning.js.map +1 -0
  65. package/dist/adapter/sheets/providers/google-sheets-api/operations/readRows.d.ts +40 -0
  66. package/dist/adapter/sheets/providers/google-sheets-api/operations/readRows.d.ts.map +1 -0
  67. package/dist/adapter/sheets/providers/google-sheets-api/operations/readRows.js +186 -0
  68. package/dist/adapter/sheets/providers/google-sheets-api/operations/readRows.js.map +1 -0
  69. package/dist/adapter/sheets/providers/google-sheets-api/operations/shared.d.ts +57 -0
  70. package/dist/adapter/sheets/providers/google-sheets-api/operations/shared.d.ts.map +1 -0
  71. package/dist/adapter/sheets/providers/google-sheets-api/operations/shared.js +130 -0
  72. package/dist/adapter/sheets/providers/google-sheets-api/operations/shared.js.map +1 -0
  73. package/package.json +1 -1
@@ -23,26 +23,16 @@
23
23
  * provisioning reads) and every `batchUpdate` acquires its class limiter and
24
24
  * emits one redacted telemetry event.
25
25
  */
26
- import { computeSyncVisibleHash, } from "../../../../application/sync/sheets/syncSheets.js";
27
26
  import { SYNC_SHEETS_ERROR_CODES, SyncSheetsContractError, } from "../../../../application/sync/sheets/errors.js";
28
- import { requireSyncSnapshotReadMode, } from "../../../../application/sync/sheets/validation.js";
29
- import { SYNC_FAST_APPEND_STATUSES, SYNC_POSTCONDITION_MODES, SYNC_PROJECTIONS, SYNC_SNAPSHOT_READ_MODES, } from "../../../../application/sync/sheets/constants.js";
30
- import { classifyTransportOutcome, } from "../../../../application/sync/sheets/transportOutcome.js";
31
- import { presentValue, absentValue } from "../../../../shared/state/index.js";
32
- import { isNormalizedCell } from "../../../../shared/encoding/index.js";
33
- import { GOOGLE_SHEETS_API_DEFAULTS, GOOGLE_SHEETS_API_EFFECT_REASONS, GOOGLE_SHEETS_API_ANCHOR_KEY, } from "./constants.js";
34
- import { invalidProviderRequest, invalidProviderState } from "./errors.js";
35
- import { identityFromNormalizedCell } from "./model/valueNormalization.js";
27
+ import { GOOGLE_SHEETS_API_DEFAULTS, } from "./constants.js";
28
+ import { invalidProviderRequest } from "./errors.js";
36
29
  import { GoogleSheetsApiHttpTransport, } from "./transport/googleSheetsApiTransport.js";
37
30
  import { RequestStartLimiter } from "./transport/rateLimiter.js";
38
- import { enumerateSheetProperties, readPreflightData, GOOGLE_SHEETS_API_PROVISION_ENUMERATION_FIELDS, GOOGLE_SHEETS_API_PROVISION_FIELDS, GOOGLE_SHEETS_API_VALUES_FIELDS, GOOGLE_SHEETS_API_OBSERVATION_FIELDS, GOOGLE_SHEETS_API_LIGHTWEIGHT_OBSERVATION_FIELDS, parseSpreadsheetDocument, gridHeaderCells, } from "./model/preflight.js";
39
- import { planEffectBatch, encodeOutcomeResult, encodeSchemaErrorResult, withDeferredPostcondition, currentHash, } from "./model/planner.js";
40
- import { buildApplyBatchRequests, buildAppendBatchRequests, resolveApplyBatchBudget, resolveAppendBudget, } from "./model/batchBuilder.js";
41
- import { classifyPostcondition } from "./model/postcondition.js";
42
- import { buildSnapshotFromTab, planRowAnchors, readTabGrids, } from "./model/observation.js";
43
- import { buildTableRowsFromGrid } from "./model/tableRead.js";
44
- import { allocateSheetId } from "./model/sheetIdAllocator.js";
45
- import { columnLetters, parseRegisteredRange, quoteA1SheetName, } from "./model/valueNormalization.js";
31
+ import { fastAppendRows, } from "./operations/fastAppend.js";
32
+ import { applyEffects, readEffectPostcondition, readEffectPostconditions, } from "./operations/applyEffects.js";
33
+ import { observeSnapshot, observeSnapshots, readRows, readRowsBatch, readSnapshot, } from "./operations/readRows.js";
34
+ import { ensureRowAnchors, } from "./operations/anchors.js";
35
+ import { provisionRegistry, } from "./operations/provisioning.js";
46
36
  /**
47
37
  * Full sync provider over the Sheets REST API: outbound effects, provisioning,
48
38
  * table reads, anchors, and snapshots behind the shared provider contracts.
@@ -52,6 +42,9 @@ import { columnLetters, parseRegisteredRange, quoteA1SheetName, } from "./model/
52
42
  * bootstrap's bulk worker options. Provisioning runs at startup before the
53
43
  * worker, and observation anchors are the only metadata mutations outside
54
44
  * effect batches.
45
+ *
46
+ * The class is a thin facade: every method body lives in an operation module
47
+ * under `operations/` and receives the immutable wiring from `this.deps`.
55
48
  */
56
49
  export class GoogleSheetsApiSyncProvider {
57
50
  spreadsheetId;
@@ -64,6 +57,7 @@ export class GoogleSheetsApiSyncProvider {
64
57
  writeLimiter;
65
58
  now;
66
59
  onRequest;
60
+ deps;
67
61
  constructor(options) {
68
62
  if (options.definitions.length === 0) {
69
63
  throw new SyncSheetsContractError(SYNC_SHEETS_ERROR_CODES.INVALID_PROVISIONING_DEFINITIONS, "Google Sheets API sync provider requires a projection definition");
@@ -108,6 +102,17 @@ export class GoogleSheetsApiSyncProvider {
108
102
  ...(options.sleep === undefined ? {} : { sleep: options.sleep }),
109
103
  });
110
104
  this.onRequest = options.onRequest;
105
+ this.deps = {
106
+ spreadsheetId: this.spreadsheetId,
107
+ definitions: this.definitions,
108
+ transport: this.transport,
109
+ readTimeoutMs: this.readTimeoutMs,
110
+ maxBatchBytes: this.maxBatchBytes,
111
+ readLimiter: this.readLimiter,
112
+ writeLimiter: this.writeLimiter,
113
+ now: this.now,
114
+ onRequest: this.onRequest,
115
+ };
111
116
  }
112
117
  /** Exposes the configured outbound timeout (used by lease-headroom checks). */
113
118
  get timeoutMs() {
@@ -118,251 +123,19 @@ export class GoogleSheetsApiSyncProvider {
118
123
  // -------------------------------------------------------------------------
119
124
  /** Appends rows through one idempotent, atomic target+receipt batch. */
120
125
  async fastAppendRows(request) {
121
- const definition = this.definitionForPhysicalSheet(request.physicalSheetId);
122
- validateRoute(request, definition);
123
- const routeOptions = effectRouteOptions(definition);
124
- if (routeOptions.identityField.kind !== "present") {
125
- // The fast path never materializes anchor metadata, so a route without
126
- // a registered identity cannot locate or guard its rows on replay.
127
- throw new SyncSheetsContractError(SYNC_SHEETS_ERROR_CODES.INVALID_EFFECT_PAYLOAD, "fast append requires a registered identityField for route " + request.physicalSheetId);
128
- }
129
- if (request.rows.length === 0) {
130
- invalidProviderRequest("fast append", "rows must not be empty");
131
- }
132
- const bounded = request.rows.slice(0, GOOGLE_SHEETS_API_DEFAULTS.MAX_APPEND_ROWS_PER_REQUEST);
133
- validateAppendRows(bounded);
134
- const context = await this.readPreflight(request, definition, routeOptions);
135
- validateAppendRowsAgainstHeaders(bounded, context.headers);
136
- const identityField = routeOptions.identityField.value;
137
- // Replay rows are recognized through the receipt tab and verified against
138
- // the visible identity; pending rows go through the identity preflight.
139
- const pending = [];
140
- const resultsById = new Map();
141
- const pendingReceipts = [];
142
- for (const row of bounded) {
143
- // The worker always supplies the outbox payload hash; fail closed when
144
- // it is absent instead of silently falling back to the effect ID.
145
- const payloadHash = row.payloadHash;
146
- if (payloadHash === undefined || payloadHash.length === 0) {
147
- invalidProviderRequest("fast append", `payloadHash is required for effectId: ${row.effectId}`);
148
- }
149
- const existing = context.receipts.get(row.effectId);
150
- if (existing !== undefined) {
151
- if (existing.payloadHash !== payloadHash) {
152
- invalidProviderRequest("fast append", `effect ID cannot be reused with another payload: ${row.effectId}`);
153
- }
154
- const identity = appendIdentity(row, identityField);
155
- const existingRow = findRowByIdentity(context, identity);
156
- if (existingRow === undefined) {
157
- invalidProviderRequest("fast append", `receipt postcondition row is unavailable for effectId: ${row.effectId}`);
158
- }
159
- if (computeSyncVisibleHash(existingRow.cells) !== existing.visibleHash) {
160
- invalidProviderRequest("fast append", `receipt postcondition changed for effectId: ${row.effectId}`);
161
- }
162
- resultsById.set(row.effectId, {
163
- effectId: row.effectId,
164
- status: SYNC_FAST_APPEND_STATUSES.APPLIED,
165
- visibleHash: existing.visibleHash,
166
- visibleRevision: existing.visibleRevision,
167
- });
168
- continue;
169
- }
170
- pending.push(row);
171
- pendingReceipts.push(makeAppendReceipt(row.effectId, payloadHash, computeSyncVisibleHash(row.fields)));
172
- }
173
- // Mirror the built-in append identity preflight: the registered identity
174
- // must exist and be unique across the sheet and the pending batch; replay
175
- // entries are exempt exactly like the real provider.
176
- let deferredSuffix = false;
177
- if (pending.length > 0) {
178
- assertAppendIdentityAvailability(context, identityField, pending);
179
- const pendingRows = pending.map((row, index) => toAppendWorkingRow(row, context.nextAppendRow + index));
180
- const updatedAt = new Date(this.now()).toISOString();
181
- const resolution = resolveAppendBudget(pendingRows, (count) => buildAppendBatchRequests(context, pendingRows.slice(0, count), pendingReceipts.slice(0, count), { updatedAt }), this.maxBatchBytes);
182
- deferredSuffix = resolution.includeCount < pending.length;
183
- if (resolution.includeCount > 0) {
184
- const batch = buildAppendBatchRequests(context, pendingRows.slice(0, resolution.includeCount), pendingReceipts.slice(0, resolution.includeCount), { updatedAt });
185
- const response = await this.runWrite(() => this.transport.batchUpdate({
186
- spreadsheetId: this.spreadsheetId,
187
- requests: batch.requests,
188
- }));
189
- requireValidBatchUpdateReply(response, batch.requests.length);
190
- pendingReceipts.slice(0, resolution.includeCount).forEach((receipt) => {
191
- resultsById.set(receipt.effectId, {
192
- effectId: receipt.effectId,
193
- status: SYNC_FAST_APPEND_STATUSES.APPLIED,
194
- visibleHash: receipt.visibleHash,
195
- visibleRevision: receipt.visibleRevision,
196
- });
197
- });
198
- }
199
- }
200
- // A byte-budget deferral commits only a prefix of the pending rows, so
201
- // results cover exactly the processed rows: receipt-matched replays plus
202
- // the included prefix. Rows beyond the included prefix are intentionally
203
- // absent from results; the worker releases them (releaseUnprocessedEffect)
204
- // for the next pass when hasMore is true.
205
- const results = [];
206
- for (const row of bounded) {
207
- const result = resultsById.get(row.effectId);
208
- if (result === undefined)
209
- continue;
210
- results.push(result);
211
- }
212
- return {
213
- results,
214
- hasMore: bounded.length < request.rows.length || deferredSuffix,
215
- };
126
+ return fastAppendRows(this.deps, request);
216
127
  }
217
128
  /** Applies regular update/delete/create effects through one atomic batch. */
218
129
  async applyEffects(request) {
219
- const definition = this.definitionForPhysicalSheet(request.physicalSheetId);
220
- validateRoute(request, definition);
221
- if (request.effects.length === 0) {
222
- invalidProviderRequest("apply effects", "effects must not be empty");
223
- }
224
- const bounded = request.effects.slice(0, GOOGLE_SHEETS_API_DEFAULTS.MAX_EFFECTS_PER_REQUEST);
225
- const postconditionMode = request.postconditionMode ?? SYNC_POSTCONDITION_MODES.INLINE;
226
- if (postconditionMode !== SYNC_POSTCONDITION_MODES.INLINE &&
227
- postconditionMode !== SYNC_POSTCONDITION_MODES.DEFERRED) {
228
- invalidProviderRequest("apply effects", "postconditionMode must be inline or deferred");
229
- }
230
- const routeOptions = effectRouteOptions(definition);
231
- const context = await this.readPreflight(request, definition, routeOptions);
232
- const plans = planEffectBatch({ ...request, effects: bounded }, context);
233
- const includeReceipts = postconditionMode === SYNC_POSTCONDITION_MODES.DEFERRED;
234
- const updatedAt = new Date(this.now()).toISOString();
235
- const resolution = resolveApplyBatchBudget(context, plans, {
236
- maxBatchBytes: this.maxBatchBytes,
237
- includeReceipts,
238
- updatedAt,
239
- });
240
- const schemaErrorIndices = new Set(resolution.schemaErrorIndices);
241
- // Schema-error effects sit BEFORE the included run; the batch only carries
242
- // the plans after them, up to the resolved include count. The included
243
- // effects are re-planned so appended rows start at the sheet's first free
244
- // row (the full-plan row numbers would leave blank gaps for excluded
245
- // effects). The planner is deterministic over the unchanged context, so
246
- // outcomes and receipts are identical to the budget-resolution plans.
247
- const includedStart = resolution.schemaErrorIndices.length;
248
- const includedEffects = bounded.slice(includedStart, resolution.includeCount);
249
- const included = planEffectBatch({ ...request, effects: includedEffects }, context);
250
- if (included.length > 0) {
251
- const batch = buildApplyBatchRequests(context, included, { updatedAt, includeReceipts });
252
- // Rejected plans (guard/schema/repair outcomes) contribute no requests;
253
- // never send an empty batchUpdate for an all-rejected prefix.
254
- if (batch.requests.length > 0) {
255
- const response = await this.runWrite(() => this.transport.batchUpdate({
256
- spreadsheetId: this.spreadsheetId,
257
- requests: batch.requests,
258
- }));
259
- requireValidBatchUpdateReply(response, batch.requests.length);
260
- }
261
- }
262
- // Inline verification reads the written rows back and demotes any hash
263
- // mismatch to retryable_error, mirroring the Apps Script inline path. The
264
- // worker always uses deferred mode, where the atomic batch already carries
265
- // target mutations and receipts together.
266
- const verified = new Set();
267
- if (postconditionMode === SYNC_POSTCONDITION_MODES.INLINE && included.length > 0) {
268
- const verifyContext = await this.readPreflight(request, definition, routeOptions);
269
- included.forEach((plan, index) => {
270
- if (!plan.verify || plan.mutation === undefined || plan.mutation.kind === "delete")
271
- return;
272
- const row = findProbeRowInContext(verifyContext, plan);
273
- const current = row === undefined ? undefined : currentHash(row, plan.outcome.effect.payload.fields);
274
- if (current === plan.outcome.effect.payload.targetVisibleHash) {
275
- verified.add(index);
276
- }
277
- });
278
- const verifyReceipts = [];
279
- included.forEach((plan, index) => {
280
- if (plan.receipt === undefined)
281
- return;
282
- // Replay receipts are already stored in the sheet; never rewrite them.
283
- if (context.receipts.has(plan.receipt.effectId))
284
- return;
285
- if (plan.outcome.kind === "applied" && !plan.outcome.deletion && !verified.has(index))
286
- return;
287
- verifyReceipts.push(plan.receipt);
288
- });
289
- if (verifyReceipts.length > 0) {
290
- const receiptBatch = buildAppendBatchRequests(context, [], verifyReceipts, { updatedAt });
291
- const response = await this.runWrite(() => this.transport.batchUpdate({
292
- spreadsheetId: this.spreadsheetId,
293
- requests: receiptBatch.requests,
294
- }));
295
- requireValidBatchUpdateReply(response, receiptBatch.requests.length);
296
- }
297
- }
298
- const results = [];
299
- let includedCursor = 0;
300
- bounded.forEach((effect, index) => {
301
- if (schemaErrorIndices.has(index)) {
302
- results.push(encodeSchemaErrorResult(effect, GOOGLE_SHEETS_API_EFFECT_REASONS.EFFECT_PAYLOAD_TOO_LARGE));
303
- return;
304
- }
305
- if (index >= resolution.includeCount)
306
- return;
307
- const planIndex = includedCursor;
308
- includedCursor += 1;
309
- const plan = included[planIndex];
310
- if (plan === undefined)
311
- return;
312
- let result = encodeOutcomeResult(plan.outcome);
313
- if (postconditionMode === SYNC_POSTCONDITION_MODES.INLINE &&
314
- plan.outcome.kind === "applied" &&
315
- !plan.outcome.deletion &&
316
- !verified.has(planIndex)) {
317
- result = {
318
- ...result,
319
- status: "retryable_error",
320
- visibleRevision: absentValue(),
321
- visibleHash: absentValue(),
322
- reason: presentValue(GOOGLE_SHEETS_API_EFFECT_REASONS.POSTCONDITION_HASH_MISMATCH),
323
- postcondition: "unavailable",
324
- };
325
- }
326
- else if (postconditionMode === SYNC_POSTCONDITION_MODES.DEFERRED) {
327
- result = withDeferredPostcondition(result);
328
- }
329
- results.push(result);
330
- });
331
- return {
332
- results,
333
- snapshotHash: absentValue(),
334
- hasMore: bounded.length < request.effects.length || results.length < bounded.length,
335
- };
130
+ return applyEffects(this.deps, request);
336
131
  }
337
132
  /** Classifies one response-loss effect through a fresh target+receipt read. */
338
133
  async readEffectPostcondition(effect) {
339
- const [result] = await this.readEffectPostconditions({
340
- physicalSheetId: effect.physicalSheetId,
341
- sheetName: effect.payload.sheetName,
342
- registeredRange: effect.payload.registeredRange,
343
- projection: effect.projection,
344
- schemaVersion: effect.payload.schemaVersion,
345
- effects: [effect],
346
- });
347
- if (result === undefined) {
348
- invalidProviderState("postcondition read returned no result");
349
- }
350
- return result.postcondition;
134
+ return readEffectPostcondition(this.deps, effect);
351
135
  }
352
136
  /** Classifies a recovery batch with one shared target+receipt read. */
353
137
  async readEffectPostconditions(request) {
354
- const definition = this.definitionForPhysicalSheet(request.physicalSheetId);
355
- validateRoute(request, definition);
356
- if (request.effects.length === 0) {
357
- invalidProviderRequest("postcondition reads", "effects must not be empty");
358
- }
359
- const routeOptions = effectRouteOptions(definition);
360
- const context = await this.readPreflight(request, definition, routeOptions);
361
- return request.effects.map((effect) => ({
362
- effectId: effect.effectId,
363
- payloadHash: effect.payloadHash,
364
- postcondition: classifyPostcondition(context, effect, context.receipts),
365
- }));
138
+ return readEffectPostconditions(this.deps, request);
366
139
  }
367
140
  // -------------------------------------------------------------------------
368
141
  // Projection provisioning (SyncSheetsProvisioner)
@@ -377,119 +150,14 @@ export class GoogleSheetsApiSyncProvider {
377
150
  * exact headers, and succeeds without rewriting anything.
378
151
  */
379
152
  async provisionRegistry(registrations) {
380
- validateProvisionRegistrations(registrations);
381
- // Enumerate every tab (no ranges, hidden included) with grid dimensions.
382
- const enumerationRaw = await this.runRead(() => this.transport.getSpreadsheet({
383
- spreadsheetId: this.spreadsheetId,
384
- ranges: [],
385
- fields: GOOGLE_SHEETS_API_PROVISION_ENUMERATION_FIELDS,
386
- timeoutMs: this.readTimeoutMs,
387
- }));
388
- const sheets = parseSpreadsheetDocument(enumerationRaw, "provisioning enumeration");
389
- const existingByTitle = new Map(sheets.sheets.map((sheet) => [sheet.title, sheet]));
390
- // Read the full used grid of every existing registered tab (values plus
391
- // formats). Missing tabs need no data read; they are created below.
392
- const dataTargets = registrations.filter((registration) => existingByTitle.has(registration.sheetName));
393
- const grids = new Map();
394
- if (dataTargets.length > 0) {
395
- const ranges = dataTargets.map((registration) => {
396
- const existing = existingByTitle.get(registration.sheetName);
397
- if (existing === undefined) {
398
- invalidProviderState(`Registered sync sheet does not exist: ${registration.sheetName}`);
399
- }
400
- const endColumn = provisionGridEndColumn(registration, existing);
401
- return `${quoteA1SheetName(registration.sheetName)}!A1:${columnLetters(endColumn)}1048576`;
402
- });
403
- const dataRaw = await this.runRead(() => this.transport.getSpreadsheet({
404
- spreadsheetId: this.spreadsheetId,
405
- ranges,
406
- fields: GOOGLE_SHEETS_API_PROVISION_FIELDS,
407
- timeoutMs: this.readTimeoutMs,
408
- }));
409
- const document = parseSpreadsheetDocument(dataRaw, "provisioning grid");
410
- for (const registration of dataTargets) {
411
- const existing = existingByTitle.get(registration.sheetName);
412
- if (existing === undefined) {
413
- invalidProviderState(`Registered sync sheet does not exist: ${registration.sheetName}`);
414
- }
415
- const grid = document.grids.get(existing.sheetId);
416
- if (grid === undefined) {
417
- invalidProviderState(`grid data is missing for sheet ${existing.sheetId}`);
418
- }
419
- grids.set(registration.sheetName, grid);
420
- }
421
- }
422
- // Plan all mutations: addSheet + header writes for missing tabs, header
423
- // writes for truly-empty tabs, exact-match verification for content tabs.
424
- const requests = [];
425
- const createdSheets = [];
426
- const initializedHeaders = [];
427
- const usedSheetIds = new Set(sheets.sheets.map((sheet) => sheet.sheetId));
428
- for (const registration of registrations) {
429
- const range = parseRegisteredRange(registration.registeredRange);
430
- const existing = existingByTitle.get(registration.sheetName);
431
- const headerCells = registration.headers.map((header) => ({
432
- userEnteredValue: { stringValue: header },
433
- }));
434
- if (existing === undefined) {
435
- const sheetId = allocateSheetId(usedSheetIds);
436
- usedSheetIds.add(sheetId);
437
- requests.push({ kind: "addSheet", title: registration.sheetName, sheetId });
438
- requests.push({
439
- kind: "updateCells",
440
- sheetId,
441
- startRowIndex: 0,
442
- startColumnIndex: range.startColumn - 1,
443
- rows: [headerCells],
444
- fields: "userEnteredValue",
445
- });
446
- createdSheets.push(registration.sheetName);
447
- initializedHeaders.push(registration.sheetName);
448
- continue;
449
- }
450
- const grid = grids.get(registration.sheetName);
451
- if (grid === undefined) {
452
- invalidProviderState(`provisioning grid is missing for ${registration.sheetName}`);
453
- }
454
- if (!gridHasContent(grid)) {
455
- // Truly empty tab: initialize the header row only.
456
- requests.push({
457
- kind: "updateCells",
458
- sheetId: existing.sheetId,
459
- startRowIndex: 0,
460
- startColumnIndex: range.startColumn - 1,
461
- rows: [headerCells],
462
- fields: "userEnteredValue",
463
- });
464
- initializedHeaders.push(registration.sheetName);
465
- continue;
466
- }
467
- // Content tab: the header row must match the registered schema exactly.
468
- assertProvisioningHeaders(grid, registration);
469
- }
470
- if (requests.length > 0) {
471
- const response = await this.runWrite(() => this.transport.batchUpdate({
472
- spreadsheetId: this.spreadsheetId,
473
- requests,
474
- }));
475
- requireValidBatchUpdateReply(response, requests.length);
476
- }
477
- return {
478
- registrations: registrations.map(({ headers: _headers, ...route }) => route),
479
- createdSheets,
480
- initializedHeaders,
481
- };
153
+ return provisionRegistry(this.deps, registrations);
482
154
  }
483
155
  // -------------------------------------------------------------------------
484
156
  // Values-only table reads (readRows / readRowsBatch)
485
157
  // -------------------------------------------------------------------------
486
158
  /** Reads one registered table's literal values with one REST read. */
487
159
  async readRows(request) {
488
- const [result] = await this.readRowsBatch([request]);
489
- if (result === undefined) {
490
- invalidProviderState("table read returned no result");
491
- }
492
- return result;
160
+ return readRows(this.deps, request);
493
161
  }
494
162
  /**
495
163
  * Reads several registered tables through ONE `spreadsheets.get` call.
@@ -497,48 +165,7 @@ export class GoogleSheetsApiSyncProvider {
497
165
  * malformed payload fails closed before any result is produced.
498
166
  */
499
167
  async readRowsBatch(requests) {
500
- if (requests.length === 0)
501
- return [];
502
- const routes = requests.map((request) => {
503
- const definition = this.definitionForPhysicalSheet(request.physicalSheetId);
504
- validateRoute(request, definition);
505
- if (request.headers.length !== definition.headers.length ||
506
- request.headers.some((header, index) => header !== definition.headers[index])) {
507
- throw new SyncSheetsContractError(SYNC_SHEETS_ERROR_CODES.INVALID_EFFECT_PAYLOAD, "sync provider table read headers do not match the registered projection " +
508
- definition.sheet.physicalSheetId);
509
- }
510
- return { request, definition };
511
- });
512
- const raw = await this.runRead(() => this.transport.getSpreadsheet({
513
- spreadsheetId: this.spreadsheetId,
514
- ranges: routes.map(({ request }) => `${quoteA1SheetName(request.sheetName)}!A1:${rangeEndColumnLetters(request.registeredRange)}1048576`),
515
- fields: GOOGLE_SHEETS_API_VALUES_FIELDS,
516
- timeoutMs: this.readTimeoutMs,
517
- }));
518
- const document = parseSpreadsheetDocument(raw, "table read");
519
- const results = [];
520
- for (const { request, definition } of routes) {
521
- const sheet = document.sheets.find((candidate) => candidate.title === request.sheetName);
522
- if (sheet === undefined) {
523
- invalidProviderState(`Registered sync sheet does not exist: ${request.sheetName}`);
524
- }
525
- const grid = document.grids.get(sheet.sheetId);
526
- if (grid === undefined) {
527
- invalidProviderState(`grid data is missing for sheet ${sheet.sheetId}`);
528
- }
529
- const rows = buildTableRowsFromGrid(grid, {
530
- registeredRange: request.registeredRange,
531
- headers: definition.headers,
532
- checkboxHeaders: definition.checkboxHeaders ?? [],
533
- });
534
- results.push({
535
- sheetName: request.sheetName,
536
- registeredRange: request.registeredRange,
537
- headers: [...definition.headers],
538
- rows,
539
- });
540
- }
541
- return results;
168
+ return readRowsBatch(this.deps, requests);
542
169
  }
543
170
  // -------------------------------------------------------------------------
544
171
  // Observation (anchors + snapshots)
@@ -550,44 +177,15 @@ export class GoogleSheetsApiSyncProvider {
550
177
  * anchors across rows are reported as evidence. No re-read is performed.
551
178
  */
552
179
  async ensureRowAnchors(request) {
553
- const definition = this.definitionForPhysicalSheet(request.physicalSheetId);
554
- validateRoute(request, definition);
555
- const tabs = await this.readObservedTabs([request]);
556
- const tab = tabs.get(request.sheetName);
557
- if (tab === undefined) {
558
- invalidProviderState(`Registered sync sheet does not exist: ${request.sheetName}`);
559
- }
560
- const plan = planRowAnchors(tab, {
561
- registeredRange: request.registeredRange,
562
- headers: definition.headers,
563
- checkboxHeaders: definition.checkboxHeaders ?? [],
564
- });
565
- if (plan.planned.length > 0) {
566
- await this.writeAnchors(tab, plan);
567
- }
568
- return {
569
- assigned: plan.assigned,
570
- existing: plan.existing,
571
- duplicateAnchors: plan.duplicateAnchors,
572
- };
180
+ return ensureRowAnchors(this.deps, request);
573
181
  }
574
182
  /** Reads one full snapshot without any mutation (lock-free). */
575
183
  async readSnapshot(request) {
576
- const target = this.observationTargetFor(request);
577
- const tabs = await this.readObservedTabs([request]);
578
- const tab = tabs.get(request.sheetName);
579
- if (tab === undefined) {
580
- invalidProviderState(`Registered sync sheet does not exist: ${request.sheetName}`);
581
- }
582
- return buildSnapshotFromTab(tab, target);
184
+ return readSnapshot(this.deps, request);
583
185
  }
584
186
  /** Combines anchor assignment and one snapshot read under one request. */
585
187
  async observeSnapshot(request) {
586
- const [observed] = await this.observeSnapshots([request]);
587
- if (observed === undefined) {
588
- invalidProviderState("observation returned no result");
589
- }
590
- return observed;
188
+ return observeSnapshot(this.deps, request);
591
189
  }
592
190
  /**
593
191
  * Observes several projections with ONE grid read, ONE anchor write (when
@@ -596,517 +194,7 @@ export class GoogleSheetsApiSyncProvider {
596
194
  * already holds every involved mutation lane before this call.
597
195
  */
598
196
  async observeSnapshots(requests) {
599
- if (requests.length === 0)
600
- return [];
601
- const targets = requests.map((request) => this.observationTargetFor(request));
602
- const tabs = await this.readObservedTabs(requests);
603
- const plans = [];
604
- let assignedTotal = 0;
605
- for (let index = 0; index < requests.length; index += 1) {
606
- const request = requests[index];
607
- const target = targets[index];
608
- if (request === undefined || target === undefined) {
609
- invalidProviderState("observation request is unavailable");
610
- }
611
- const tab = tabs.get(request.sheetName);
612
- if (tab === undefined) {
613
- invalidProviderState(`Registered sync sheet does not exist: ${request.sheetName}`);
614
- }
615
- const anchors = planRowAnchors(tab, {
616
- registeredRange: request.registeredRange,
617
- headers: target.headers,
618
- checkboxHeaders: target.checkboxHeaders,
619
- });
620
- assignedTotal += anchors.assigned;
621
- plans.push({ request, target, tab, anchors });
622
- }
623
- if (assignedTotal > 0) {
624
- // One atomic write for every planned anchor across all requested tabs.
625
- const anchorRequests = [];
626
- for (const entry of plans) {
627
- for (const planned of entry.anchors.planned) {
628
- anchorRequests.push({
629
- kind: "createDeveloperMetadata",
630
- sheetId: entry.tab.sheetId,
631
- rowIndex: planned.rowIndex,
632
- key: GOOGLE_SHEETS_API_ANCHOR_KEY,
633
- value: planned.anchor,
634
- });
635
- }
636
- }
637
- const response = await this.runWrite(() => this.transport.batchUpdate({
638
- spreadsheetId: this.spreadsheetId,
639
- requests: anchorRequests,
640
- }));
641
- requireValidBatchUpdateReply(response, anchorRequests.length);
642
- // One shared re-read so the committed anchors appear in the snapshots
643
- // (mirrors the Apps Script flush-while-locked behavior).
644
- const refreshed = await this.readObservedTabs(requests);
645
- for (const entry of plans) {
646
- const tab = refreshed.get(entry.request.sheetName);
647
- if (tab === undefined) {
648
- invalidProviderState(`Registered sync sheet does not exist: ${entry.request.sheetName}`);
649
- }
650
- entry.tab = tab;
651
- }
652
- }
653
- return plans.map((entry) => ({
654
- anchors: {
655
- assigned: entry.anchors.assigned,
656
- existing: entry.anchors.existing,
657
- duplicateAnchors: entry.anchors.duplicateAnchors,
658
- },
659
- snapshot: buildSnapshotFromTab(entry.tab, entry.target),
660
- }));
661
- }
662
- // -------------------------------------------------------------------------
663
- // Internal helpers
664
- // -------------------------------------------------------------------------
665
- /** Reads the target grids of one or more observation requests (one call). */
666
- async readObservedTabs(requests) {
667
- // One getSpreadsheet call serves the whole batch with ONE mask: the
668
- // lighter user_input mask only when every request is lightweight (the
669
- // lightweight branch never consults merges or dataValidation; the full
670
- // mask is still correct for a mixed batch).
671
- const lightweight = requests.length > 0 && requests.every((request) => request.readMode === SYNC_SNAPSHOT_READ_MODES.USER_INPUT);
672
- const fields = lightweight
673
- ? GOOGLE_SHEETS_API_LIGHTWEIGHT_OBSERVATION_FIELDS
674
- : GOOGLE_SHEETS_API_OBSERVATION_FIELDS;
675
- return this.runRead(() => readTabGrids(this.transport, this.spreadsheetId, requests.map((request) => ({
676
- sheetName: request.sheetName,
677
- registeredRange: request.registeredRange,
678
- })), fields, this.readTimeoutMs));
679
- }
680
- /** Writes every planned anchor of one tab in one atomic batch. */
681
- async writeAnchors(tab, plan) {
682
- const requests = plan.planned.map((planned) => ({
683
- kind: "createDeveloperMetadata",
684
- sheetId: tab.sheetId,
685
- rowIndex: planned.rowIndex,
686
- key: GOOGLE_SHEETS_API_ANCHOR_KEY,
687
- value: planned.anchor,
688
- }));
689
- const response = await this.runWrite(() => this.transport.batchUpdate({
690
- spreadsheetId: this.spreadsheetId,
691
- requests,
692
- }));
693
- requireValidBatchUpdateReply(response, requests.length);
694
- }
695
- /** Validates one observation request and derives its snapshot target. */
696
- observationTargetFor(request) {
697
- const definition = this.definitionForPhysicalSheet(request.physicalSheetId);
698
- validateRoute(request, definition);
699
- // Fail closed on unknown readMode strings (same shared guard as the Apps
700
- // Script observation operation) instead of silently reading in full mode.
701
- const readMode = request.readMode === undefined
702
- ? SYNC_SNAPSHOT_READ_MODES.FULL
703
- : requireSyncSnapshotReadMode(request.readMode, "Google Sheets API observation readMode", SYNC_SHEETS_ERROR_CODES.INVALID_EFFECT_PAYLOAD);
704
- if (readMode === SYNC_SNAPSHOT_READ_MODES.USER_INPUT &&
705
- request.projection !== SYNC_PROJECTIONS.USER_INPUT) {
706
- invalidProviderRequest("observation", "user_input readMode requires the user_input projection");
707
- }
708
- return {
709
- sheetName: request.sheetName,
710
- registeredRange: request.registeredRange,
711
- projection: request.projection,
712
- schemaVersion: request.schemaVersion,
713
- headers: definition.headers,
714
- checkboxHeaders: definition.checkboxHeaders ?? [],
715
- readMode,
716
- };
717
- }
718
- /** Reads the target and receipt tabs for one route through the read lane. */
719
- async readPreflight(request, definition, routeOptions) {
720
- // Each preflight performs two paced transport calls: a range-less sheet
721
- // enumeration (hidden receipt tab discovery) plus one ranged data read.
722
- const sheets = await this.runRead(() => enumerateSheetProperties(this.transport, this.spreadsheetId, this.readTimeoutMs));
723
- return this.runRead(() => readPreflightData(this.transport, {
724
- spreadsheetId: this.spreadsheetId,
725
- sheetName: request.sheetName,
726
- registeredRange: request.registeredRange,
727
- headers: definition.headers,
728
- identityField: routeOptions.identityField,
729
- checkboxHeaders: routeOptions.checkboxHeaders,
730
- }, sheets, this.readTimeoutMs));
731
- }
732
- /** Paces ONE `getSpreadsheet` transport call and emits one read event. */
733
- async runRead(task) {
734
- await this.readLimiter.waitForSlot();
735
- const startedAt = this.now();
736
- try {
737
- const result = await task();
738
- this.emitRequest("getSpreadsheet", 1, startedAt, true, absentValue(), absentValue());
739
- return result;
740
- }
741
- catch (error) {
742
- const outcome = classifyTransportOutcome(error);
743
- this.emitRequest("getSpreadsheet", 1, startedAt, false, outcome.httpStatus, outcome.code);
744
- throw error;
745
- }
746
- }
747
- /** Paces ONE `batchUpdate` transport call and emits one write event. */
748
- async runWrite(task) {
749
- await this.writeLimiter.waitForSlot();
750
- const startedAt = this.now();
751
- try {
752
- const result = await task();
753
- this.emitRequest("batchUpdate", 1, startedAt, true, absentValue(), absentValue());
754
- return result;
755
- }
756
- catch (error) {
757
- const outcome = classifyTransportOutcome(error);
758
- this.emitRequest("batchUpdate", 1, startedAt, false, outcome.httpStatus, outcome.code);
759
- throw error;
760
- }
761
- }
762
- emitRequest(operation, operationCount, startedAt, ok, httpStatus, code) {
763
- try {
764
- this.onRequest?.({
765
- operation,
766
- operationCount,
767
- startedAt,
768
- durationMs: Math.max(0, this.now() - startedAt),
769
- ok,
770
- httpStatus,
771
- code,
772
- });
773
- }
774
- catch {
775
- // Diagnostics must never change a remote result.
776
- }
777
- }
778
- definitionForPhysicalSheet(physicalSheetId) {
779
- const definition = this.definitions.find((candidate) => candidate.sheet.physicalSheetId === physicalSheetId);
780
- if (definition === undefined) {
781
- throw new SyncSheetsContractError(SYNC_SHEETS_ERROR_CODES.INVALID_PROVISIONING_DEFINITIONS, "no projection definition exists for " + physicalSheetId);
782
- }
783
- return definition;
784
- }
785
- }
786
- // ---------------------------------------------------------------------------
787
- // Outbound helpers (ported from the Apps Script effect operations)
788
- // ---------------------------------------------------------------------------
789
- /** Builds the fixed-shape receipt record used by the fast-append path. */
790
- function makeAppendReceipt(effectId, payloadHash, visibleHash) {
791
- return {
792
- effectId,
793
- payloadHash,
794
- status: "applied",
795
- visibleHash,
796
- visibleRevision: 1,
797
- };
798
- }
799
- /** Validates the append rows before any remote read or write. */
800
- function validateAppendRows(rows) {
801
- const seenEffectIds = new Set();
802
- for (const row of rows) {
803
- if (row.effectId.length === 0 || seenEffectIds.has(row.effectId)) {
804
- invalidProviderRequest("fast append", "effectIds must be non-empty and unique");
805
- }
806
- seenEffectIds.add(row.effectId);
807
- const payloadHash = row.payloadHash;
808
- if (payloadHash === undefined || payloadHash.length === 0) {
809
- invalidProviderRequest("fast append", "payloadHash is required");
810
- }
811
- if (row.anchor !== undefined && row.anchor.length === 0) {
812
- invalidProviderRequest("fast append", "row anchor must be non-empty");
813
- }
814
- const fields = row.fields;
815
- if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
816
- invalidProviderRequest("fast append", "row fields must be an object");
817
- }
818
- if (Object.keys(fields).length === 0) {
819
- invalidProviderRequest("fast append", "row fields are required");
820
- }
821
- for (const value of Object.values(fields)) {
822
- if (!isNormalizedCell(value)) {
823
- invalidProviderRequest("fast append", "row fields contain an invalid normalized cell");
824
- }
825
- }
826
- }
827
- }
828
- /** Append rows must cover exactly the registered headers (batch append rule). */
829
- function validateAppendRowsAgainstHeaders(rows, headers) {
830
- const expected = [...headers].sort();
831
- for (const row of rows) {
832
- const actual = Object.keys(row.fields).sort();
833
- if (actual.length !== expected.length ||
834
- actual.some((field, index) => field !== expected[index])) {
835
- invalidProviderRequest("fast append", "rows must contain exactly the registered headers");
836
- }
837
- }
838
- }
839
- /**
840
- * Derives the append identity from the row's identity field cell, using the
841
- * canonical identity rule (non-empty string or finite number) shared with
842
- * the append/replay paths.
843
- */
844
- function appendIdentity(row, identityField) {
845
- const cell = row.fields[identityField] ?? null;
846
- const identity = identityFromNormalizedCell(cell);
847
- if (identity === null) {
848
- invalidProviderRequest("fast append", `sync identity is required for append: ${identityField}`);
849
- }
850
- return identity;
851
- }
852
- /** Finds a preflight row by its visible identity (single match or fail closed). */
853
- function findRowByIdentity(context, identity) {
854
- const matches = context.rows.filter((row) => row.identity.kind === "present" && row.identity.value === identity);
855
- if (matches.length > 1) {
856
- invalidProviderState(`sync identity is duplicated: ${identity}`);
857
- }
858
- return matches[0];
859
- }
860
- /**
861
- * Mirrors the built-in append identity preflight: every existing data row
862
- * needs a unique identity, and every pending non-replay row needs a fresh,
863
- * non-empty identity.
864
- */
865
- function assertAppendIdentityAvailability(context, identityField, pending) {
866
- const existing = new Map();
867
- context.rows.forEach((row) => {
868
- if (row.identity.kind !== "present") {
869
- invalidProviderState(`sync identity is missing at row ${row.rowNumber}`);
870
- }
871
- const location = existing.get(row.identity.value);
872
- if (location !== undefined) {
873
- invalidProviderState(`sync identity is duplicated: ${row.identity.value} at rows ${location} and ${row.rowNumber}`);
874
- }
875
- existing.set(row.identity.value, String(row.rowNumber));
876
- });
877
- for (const row of pending) {
878
- const identity = appendIdentity(row, identityField);
879
- const location = existing.get(identity);
880
- if (location !== undefined) {
881
- invalidProviderState(`sync identity already exists: ${identity} at ${location}`);
882
- }
883
- existing.set(identity, "pending");
884
- }
885
- }
886
- /** Builds a working row for one pending append at its reserved position. */
887
- function toAppendWorkingRow(row, rowNumber) {
888
- // The fast path never materializes anchor metadata (the Apps Script batch
889
- // append path ignores the advisory row anchor); the row replays by identity.
890
- return {
891
- rowNumber,
892
- anchor: absentValue(),
893
- cells: { ...row.fields },
894
- identity: absentValue(),
895
- appended: true,
896
- deleted: false,
897
- writeFields: {},
898
- };
899
- }
900
- /** Locates one planned write's row in a fresh verification context. */
901
- function findProbeRowInContext(context, plan) {
902
- const mutation = plan.mutation;
903
- if (mutation === undefined)
904
- return undefined;
905
- if (mutation.kind === "append") {
906
- return context.rows.find((row) => row.rowNumber === mutation.row.rowNumber);
907
- }
908
- const anchor = mutation.row.anchor;
909
- if (anchor.kind === "present") {
910
- return context.rows.find((row) => row.physicalAnchor.kind === "present" && row.physicalAnchor.value === anchor.value);
911
- }
912
- const identity = mutation.row.identity;
913
- if (identity.kind === "present") {
914
- return context.rows.find((row) => row.identity.kind === "present" && row.identity.value === identity.value);
915
- }
916
- return undefined;
917
- }
918
- /**
919
- * Route validation against the registered definition (mirrors
920
- * `validateRoute` in the Apps Script operation provider).
921
- */
922
- function validateRoute(request, definition) {
923
- if (request.sheetName !== definition.sheet.tabName ||
924
- request.registeredRange !== definition.sheet.registeredRange ||
925
- request.projection !== definition.sheet.projection ||
926
- request.schemaVersion !== definition.sheet.schemaVersion) {
927
- throw new SyncSheetsContractError(SYNC_SHEETS_ERROR_CODES.INVALID_EFFECT_PAYLOAD, "sync provider request does not match the registered projection " +
928
- definition.sheet.physicalSheetId);
929
- }
930
- }
931
- /** Derives the per-route effect options exactly like the Apps Script provider. */
932
- function effectRouteOptions(definition) {
933
- const identityField = definition.sheet.projection === "system_state"
934
- ? definition.sheet.businessKeyField
935
- : definition.sheet.projection === "sync_conflicts"
936
- ? "Conflict_ID"
937
- : undefined;
938
- return {
939
- identityField: identityField === undefined
940
- ? absentValue()
941
- : presentValue(identityField),
942
- checkboxHeaders: definition.checkboxHeaders ?? [],
943
- };
944
- }
945
- /**
946
- * Validates a batchUpdate reply shape: one reply per request, with the
947
- * addSheet reply carrying the created sheet id. A malformed 2xx response must
948
- * not close effects, so this throws a delivery-uncertain state error.
949
- */
950
- function requireValidBatchUpdateReply(value, requestCount) {
951
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
952
- invalidProviderState("batchUpdate response must be an object");
953
- }
954
- const record = value;
955
- if (!Array.isArray(record.replies) || record.replies.length !== requestCount) {
956
- invalidProviderState(`batchUpdate reply count does not match ${requestCount} requests`);
957
- }
958
- record.replies.forEach((reply, index) => {
959
- if (reply === null || typeof reply !== "object" || Array.isArray(reply)) {
960
- invalidProviderState(`batchUpdate reply[${index}] must be an object`);
961
- }
962
- const replyRecord = reply;
963
- const addSheet = replyRecord.addSheet;
964
- if (addSheet === undefined)
965
- return;
966
- if (addSheet === null || typeof addSheet !== "object" || Array.isArray(addSheet)) {
967
- invalidProviderState(`batchUpdate reply[${index}].addSheet is invalid`);
968
- }
969
- const properties = addSheet.properties;
970
- if (properties === null || typeof properties !== "object" || Array.isArray(properties)) {
971
- invalidProviderState(`batchUpdate reply[${index}].addSheet.properties is invalid`);
972
- }
973
- if (typeof properties.sheetId !== "number") {
974
- invalidProviderState(`batchUpdate reply[${index}].addSheet.properties.sheetId is invalid`);
975
- }
976
- });
977
- }
978
- // ---------------------------------------------------------------------------
979
- // Provisioning helpers
980
- // ---------------------------------------------------------------------------
981
- /** Validates provisioning registrations before any transport call. */
982
- function validateProvisionRegistrations(registrations) {
983
- if (registrations.length === 0) {
984
- invalidProviderRequest("provisioning", "registrations must not be empty");
985
- }
986
- const tabNames = new Set();
987
- for (const registration of registrations) {
988
- if (registration.sheetName.trim() === "") {
989
- invalidProviderRequest("provisioning", "sheetName must be non-empty");
990
- }
991
- if (tabNames.has(registration.sheetName)) {
992
- invalidProviderRequest("provisioning", `cannot repeat a tab name: ${registration.sheetName}`);
993
- }
994
- tabNames.add(registration.sheetName);
995
- const range = parseRegisteredRange(registration.registeredRange);
996
- if (range.columnCount !== registration.headers.length) {
997
- invalidProviderRequest("provisioning", `headers do not match registeredRange for ${registration.sheetName}`);
998
- }
999
- if (registration.headers.length === 0 ||
1000
- registration.headers.some((header) => header.trim() === "")) {
1001
- invalidProviderRequest("provisioning", `headers must contain non-empty names for ${registration.sheetName}`);
1002
- }
1003
- if (new Set(registration.headers).size !== registration.headers.length) {
1004
- invalidProviderRequest("provisioning", `headers must not contain duplicates for ${registration.sheetName}`);
1005
- }
1006
- }
1007
- }
1008
- /**
1009
- * Builds the provisioning data-read end column: the sheet's actual grid
1010
- * width when the enumeration supplied it (so content anywhere in the tab
1011
- * decides emptiness), otherwise the registered range's end column and
1012
- * emptiness is judged only from the returned grid.
1013
- */
1014
- function provisionGridEndColumn(registration, existing) {
1015
- const parsed = parseRegisteredRange(registration.registeredRange);
1016
- const registeredEnd = parsed.startColumn + parsed.columnCount - 1;
1017
- const gridColumns = existing.gridProperties?.columnCount;
1018
- if (gridColumns === undefined)
1019
- return registeredEnd;
1020
- return Math.max(gridColumns, registeredEnd);
1021
- }
1022
- /** Builds the A1 range end letters for one registered range. */
1023
- function rangeEndColumnLetters(registeredRange) {
1024
- const range = parseRegisteredRange(registeredRange);
1025
- return columnLetters(range.startColumn + range.columnCount - 1);
1026
- }
1027
- /**
1028
- * Returns whether a provisioning grid has any content anywhere.
1029
- *
1030
- * Only cells with an actual ENTERED value count (a userEnteredValue carrying
1031
- * stringValue/numberValue/boolValue/formulaValue). Blank `{}` cells and
1032
- * format-only cells (userEnteredFormat without a value) are ignored, matching
1033
- * the Apps Script `getLastRow()/getLastColumn()` semantics provisioning was
1034
- * ported from — a blank-but-formatted tab is still initialized, never judged
1035
- * as a content tab that must match headers.
1036
- */
1037
- function gridHasContent(grid) {
1038
- for (const row of grid.rowData) {
1039
- for (const value of row.values) {
1040
- if (cellHasEnteredValue(value))
1041
- return true;
1042
- }
1043
- }
1044
- return false;
1045
- }
1046
- /** Returns whether one API cell carries a real entered value. */
1047
- function cellHasEnteredValue(value) {
1048
- if (value === null || typeof value !== "object" || Array.isArray(value))
1049
- return false;
1050
- const entered = value.userEnteredValue;
1051
- if (entered === null || typeof entered !== "object" || Array.isArray(entered))
1052
- return false;
1053
- const enteredRecord = entered;
1054
- return enteredRecord.stringValue !== undefined ||
1055
- enteredRecord.numberValue !== undefined ||
1056
- enteredRecord.boolValue !== undefined ||
1057
- enteredRecord.formulaValue !== undefined;
1058
- }
1059
- /**
1060
- * Verifies one content tab's header row against the registered schema:
1061
- * exact order, non-empty strings, no duplicates, and full registered-range
1062
- * coverage. Any drift fails closed BEFORE any mutation, mirroring the Apps
1063
- * Script "operational provisioning header mismatch" behavior (including a
1064
- * blank header row on a tab whose content lives outside the registered
1065
- * range).
1066
- */
1067
- function assertProvisioningHeaders(grid, registration) {
1068
- const range = parseRegisteredRange(registration.registeredRange);
1069
- const headerValues = gridHeaderCells(grid, range);
1070
- const actual = headerValues.map((value, index) => {
1071
- const raw = provisioningHeaderString(value);
1072
- if (raw === null) {
1073
- invalidProviderState(`operational provisioning header mismatch: ${registration.sheetName}` +
1074
- ` (header is missing at column ${index + 1})`);
1075
- }
1076
- return raw;
1077
- });
1078
- if (new Set(actual).size !== actual.length) {
1079
- invalidProviderState(`operational provisioning header mismatch: ${registration.sheetName} (duplicate header)`);
1080
- }
1081
- if (actual.length !== registration.headers.length ||
1082
- actual.some((header, index) => header !== registration.headers[index])) {
1083
- invalidProviderState(`operational provisioning header mismatch: ${registration.sheetName}`);
1084
- }
1085
- }
1086
- /**
1087
- * Reads a provisioning header cell as its raw string: string values as-is,
1088
- * numbers and booleans stringified (the Apps Script source compares
1089
- * `String(actual) === String(expected)`), anything else treated as missing.
1090
- */
1091
- function provisioningHeaderString(value) {
1092
- if (value === null || typeof value !== "object")
1093
- return null;
1094
- const record = value;
1095
- const entered = record.userEnteredValue;
1096
- if (entered === null || typeof entered !== "object")
1097
- return null;
1098
- const enteredRecord = entered;
1099
- if (enteredRecord.stringValue !== undefined) {
1100
- return typeof enteredRecord.stringValue === "string" ? enteredRecord.stringValue : null;
1101
- }
1102
- if (enteredRecord.numberValue !== undefined) {
1103
- return typeof enteredRecord.numberValue === "number" && Number.isFinite(enteredRecord.numberValue)
1104
- ? String(enteredRecord.numberValue)
1105
- : null;
1106
- }
1107
- if (enteredRecord.boolValue !== undefined) {
1108
- return typeof enteredRecord.boolValue === "boolean" ? String(enteredRecord.boolValue) : null;
197
+ return observeSnapshots(this.deps, requests);
1109
198
  }
1110
- return null;
1111
199
  }
1112
200
  //# sourceMappingURL=GoogleSheetsApiSyncProvider.js.map