@syncular/server 0.15.44 → 0.15.46

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 (93) hide show
  1. package/README.md +135 -5
  2. package/dist/admin.d.ts +12 -6
  3. package/dist/admin.js +11 -1
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/blob-store.d.ts +2 -2
  7. package/dist/context.d.ts +11 -2
  8. package/dist/context.js +2 -0
  9. package/dist/d1-storage.d.ts +10 -1
  10. package/dist/d1-storage.js +219 -3
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.js +43 -1
  13. package/dist/events-ring.d.ts +1 -1
  14. package/dist/events-ring.js +1 -1
  15. package/dist/events.d.ts +52 -3
  16. package/dist/handler.js +6 -3
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.js +5 -1
  19. package/dist/operations-realtime.d.ts +16 -0
  20. package/dist/operations-realtime.js +196 -0
  21. package/dist/operations.d.ts +97 -0
  22. package/dist/operations.js +392 -0
  23. package/dist/pg-executor.d.ts +1 -1
  24. package/dist/pg-executor.js +1 -1
  25. package/dist/postgres-fanout.d.ts +1 -1
  26. package/dist/postgres-storage.d.ts +11 -2
  27. package/dist/postgres-storage.js +223 -3
  28. package/dist/pull.js +1 -1
  29. package/dist/push.d.ts +8 -2
  30. package/dist/push.js +75 -21
  31. package/dist/reactions.d.ts +167 -0
  32. package/dist/reactions.js +442 -0
  33. package/dist/realtime.js +4 -1
  34. package/dist/relational-rows.d.ts +9 -9
  35. package/dist/relational-rows.js +9 -9
  36. package/dist/s3-blob-store.js +1 -1
  37. package/dist/s3-segment-store.js +1 -1
  38. package/dist/schema.d.ts +4 -5
  39. package/dist/schema.js +3 -3
  40. package/dist/seed.js +1 -1
  41. package/dist/segment-store.d.ts +3 -3
  42. package/dist/signed-url.js +2 -2
  43. package/dist/sigv4.d.ts +2 -4
  44. package/dist/sigv4.js +3 -3
  45. package/dist/sqlite-blob-store.d.ts +1 -1
  46. package/dist/sqlite-blob-store.js +1 -1
  47. package/dist/sqlite-dialect.d.ts +3 -4
  48. package/dist/sqlite-dialect.js +21 -1
  49. package/dist/sqlite-image.d.ts +1 -1
  50. package/dist/sqlite-lease-store.d.ts +1 -1
  51. package/dist/sqlite-lease-store.js +1 -1
  52. package/dist/sqlite-segment-store.d.ts +2 -2
  53. package/dist/sqlite-segment-store.js +2 -2
  54. package/dist/sqlite-storage.d.ts +11 -2
  55. package/dist/sqlite-storage.js +219 -4
  56. package/dist/storage.d.ts +112 -3
  57. package/dist/validate.js +1 -0
  58. package/package.json +2 -2
  59. package/src/admin.ts +30 -6
  60. package/src/authoritative-query.ts +218 -0
  61. package/src/blob-store.ts +0 -0
  62. package/src/context.ts +12 -2
  63. package/src/d1-storage.ts +355 -3
  64. package/src/errors.ts +43 -1
  65. package/src/events-ring.ts +1 -1
  66. package/src/events.ts +64 -2
  67. package/src/handler.ts +15 -3
  68. package/src/index.ts +33 -1
  69. package/src/operations-realtime.ts +272 -0
  70. package/src/operations.ts +720 -0
  71. package/src/pg-executor.ts +1 -1
  72. package/src/postgres-fanout.ts +1 -1
  73. package/src/postgres-storage.ts +355 -4
  74. package/src/pull.ts +1 -1
  75. package/src/push.ts +97 -29
  76. package/src/reactions.ts +741 -0
  77. package/src/realtime.ts +7 -1
  78. package/src/relational-rows.ts +9 -9
  79. package/src/s3-blob-store.ts +1 -1
  80. package/src/s3-segment-store.ts +1 -1
  81. package/src/schema.ts +6 -7
  82. package/src/seed.ts +1 -1
  83. package/src/segment-store.ts +3 -3
  84. package/src/signed-url.ts +2 -2
  85. package/src/sigv4.ts +3 -5
  86. package/src/sqlite-blob-store.ts +1 -1
  87. package/src/sqlite-dialect.ts +22 -3
  88. package/src/sqlite-image.ts +1 -1
  89. package/src/sqlite-lease-store.ts +1 -1
  90. package/src/sqlite-segment-store.ts +2 -2
  91. package/src/sqlite-storage.ts +369 -4
  92. package/src/storage.ts +168 -3
  93. package/src/validate.ts +1 -0
package/src/errors.ts CHANGED
@@ -14,8 +14,50 @@ export interface ErrorCatalogEntry {
14
14
  readonly httpStatus: number;
15
15
  }
16
16
 
17
- /** The §10.2 wire catalog (21 sync.* + 4 blob.* codes), keyed by stable code. */
17
+ /** The §10.2 wire catalog, keyed by stable code. */
18
18
  export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
19
+ 'operation.unknown': {
20
+ category: 'not-found',
21
+ retryable: false,
22
+ recommendedAction: 'regenerateClient',
23
+ httpStatus: 404,
24
+ },
25
+ 'operation.forbidden': {
26
+ category: 'forbidden',
27
+ retryable: false,
28
+ recommendedAction: 'checkPermissions',
29
+ httpStatus: 403,
30
+ },
31
+ 'operation.invalid_request': {
32
+ category: 'invalid-request',
33
+ retryable: false,
34
+ recommendedAction: 'fixRequest',
35
+ httpStatus: 400,
36
+ },
37
+ 'operation.result_too_large': {
38
+ category: 'invalid-request',
39
+ retryable: false,
40
+ recommendedAction: 'fixRequest',
41
+ httpStatus: 400,
42
+ },
43
+ 'operation.storage_unsupported': {
44
+ category: 'internal',
45
+ retryable: false,
46
+ recommendedAction: 'inspectServer',
47
+ httpStatus: 500,
48
+ },
49
+ 'operation.query_failed': {
50
+ category: 'internal',
51
+ retryable: false,
52
+ recommendedAction: 'inspectServer',
53
+ httpStatus: 500,
54
+ },
55
+ 'operation.execution_failed': {
56
+ category: 'internal',
57
+ retryable: false,
58
+ recommendedAction: 'inspectServer',
59
+ httpStatus: 500,
60
+ },
19
61
  'sync.auth_required': {
20
62
  category: 'auth-required',
21
63
  retryable: true,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Event ring buffer + sink composition (TODO §2.5) — the "event stream"
2
+ * Event ring buffer + sink composition: the "event stream"
3
3
  * without any infrastructure dependency.
4
4
  *
5
5
  * `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
package/src/events.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Structured server events — the one operational seam (host surface, not
3
- * wire protocol; SPEC.md is untouched by this module).
2
+ * Structured server events — the one operational seam (host surface, outside
3
+ * the wire protocol).
4
4
  *
5
5
  * Design rules:
6
6
  * - Every event is a flat, JSON-able object with a stable `type` string:
@@ -81,6 +81,62 @@ export interface PushConflictedEvent extends PushEventBase {
81
81
  readonly cacheIdentity?: string;
82
82
  }
83
83
 
84
+ /** A planned reaction committed atomically with its accepted source commit. */
85
+ export interface ReactionQueuedEvent {
86
+ readonly type: 'reaction.queued';
87
+ readonly atMs: number;
88
+ readonly partition: string;
89
+ readonly actorId: string;
90
+ readonly clientId: string;
91
+ readonly clientCommitId: string;
92
+ readonly commitSeq: number;
93
+ readonly idempotencyKey: string;
94
+ readonly reactionType: string;
95
+ readonly version: number;
96
+ }
97
+
98
+ interface ReactionDeliveryEventBase {
99
+ readonly atMs: number;
100
+ readonly partition: string;
101
+ readonly workerId: string;
102
+ readonly idempotencyKey: string;
103
+ readonly reactionType: string;
104
+ readonly version: number;
105
+ readonly attempt: number;
106
+ }
107
+
108
+ export interface ReactionStartedEvent extends ReactionDeliveryEventBase {
109
+ readonly type: 'reaction.started';
110
+ }
111
+
112
+ export interface ReactionRetriedEvent extends ReactionDeliveryEventBase {
113
+ readonly type: 'reaction.retried';
114
+ readonly nextAttemptAtMs: number;
115
+ readonly errorCode: string;
116
+ }
117
+
118
+ export interface ReactionCompletedEvent extends ReactionDeliveryEventBase {
119
+ readonly type: 'reaction.completed';
120
+ }
121
+
122
+ export interface ReactionDeadLetteredEvent extends ReactionDeliveryEventBase {
123
+ readonly type: 'reaction.dead_lettered';
124
+ readonly errorCode: string;
125
+ }
126
+
127
+ /** One bounded terminal-reaction retention pass. */
128
+ export interface ReactionPruneCompletedEvent {
129
+ readonly type: 'reaction.prune_completed';
130
+ readonly atMs: number;
131
+ readonly partition: string;
132
+ readonly completedBeforeMs: number;
133
+ readonly deadLetterBeforeMs: number;
134
+ readonly limit: number;
135
+ readonly removedCompleted: number;
136
+ readonly removedDeadLetter: number;
137
+ readonly mayHaveMore: boolean;
138
+ }
139
+
84
140
  /** One emitted segment within a pull subscription section. */
85
141
  export interface PullSegmentSummary {
86
142
  readonly mediaType: 'rows' | 'sqlite';
@@ -248,6 +304,12 @@ export type SyncularServerEvent =
248
304
  | PushAppliedEvent
249
305
  | PushRejectedEvent
250
306
  | PushConflictedEvent
307
+ | ReactionQueuedEvent
308
+ | ReactionStartedEvent
309
+ | ReactionRetriedEvent
310
+ | ReactionCompletedEvent
311
+ | ReactionDeadLetteredEvent
312
+ | ReactionPruneCompletedEvent
251
313
  | PullServedEvent
252
314
  | SegmentDownloadedEvent
253
315
  | BlobUploadedEvent
package/src/handler.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `handleSyncRequest(bytes, ctx) → bytes` (SPEC.md §1, REVISE B2).
2
+ * `handleSyncRequest(bytes, ctx) → bytes` (SPEC.md §1).
3
3
  *
4
4
  * Internally streaming-friendly (§1.4): `createSyncResponseStream` returns
5
5
  * an async iterable of encoded chunks — one per frame — after performing
@@ -23,7 +23,12 @@ import {
23
23
  type SubscriptionFrame,
24
24
  } from '@syncular/core';
25
25
  import type { SyncRequestContext } from './context';
26
- import { clockOf, limitsOf, RESOLVER_OUTAGE } from './context';
26
+ import {
27
+ clockOf,
28
+ limitsOf,
29
+ REMOTE_COMMAND_CLIENT_ID_PREFIX,
30
+ RESOLVER_OUTAGE,
31
+ } from './context';
27
32
  import { SyncError, syncError } from './errors';
28
33
  import {
29
34
  emitEvent,
@@ -215,6 +220,13 @@ async function planRequest(
215
220
  };
216
221
  }
217
222
 
223
+ if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
224
+ throw syncError(
225
+ 'sync.invalid_client_id',
226
+ 'clientId uses a reserved server-command namespace (§1.5)',
227
+ );
228
+ }
229
+
218
230
  // §1.5: a clientId already bound to a different actor is rejected.
219
231
  const record = await ctx.storage.getClientRecord(
220
232
  ctx.partition,
@@ -629,7 +641,7 @@ async function createStreamCore(
629
641
  throw error;
630
642
  }
631
643
  const schema = compileSchema(ctx.schema);
632
- // Relational row tables (DESIGN-relational-server-storage.md): create/
644
+ // Relational row tables: create/
633
645
  // migrate on first contact; memoized per storage instance thereafter.
634
646
  await ctx.storage.ensureSchema(schema);
635
647
  const plan = await planRequest(request, ctx, schema);
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @syncular/server — framework-free embeddable SSP2 protocol library
3
- * (SPEC.md is normative; REVISE.md B2 is the architectural mandate).
3
+ * (SPEC.md is normative).
4
4
  *
5
5
  * Core surface: `handleSyncRequest(bytes, ctx) → bytes` over host-provided
6
6
  * storage / scope-resolution / segment-store interfaces, plus a
@@ -8,6 +8,7 @@
8
8
  * handler (§5.5), and signed-URL token issuance/verification (§5.4).
9
9
  */
10
10
  export * from './admin';
11
+ export * from './authoritative-query';
11
12
  export * from './blob-handlers';
12
13
  export * from './blob-store';
13
14
  export * from './content-encoding';
@@ -20,6 +21,8 @@ export * from './events-ring';
20
21
  export * from './frame-bytes';
21
22
  export * from './handler';
22
23
  export * from './lease-store';
24
+ export * from './operations';
25
+ export * from './operations-realtime';
23
26
  // The `PgExecutor` seam + Postgres storage/fanout are driver-agnostic (zero
24
27
  // runtime deps). Concrete driver adapters (pglite for tests; Bun.sql /
25
28
  // node-postgres for production, documented in the README) live in separate
@@ -31,6 +34,35 @@ export * from './prune';
31
34
  export * from './pull';
32
35
  export * from './push';
33
36
  export * from './readiness';
37
+ export {
38
+ DEFAULT_REACTION_INITIAL_BACKOFF_MS,
39
+ DEFAULT_REACTION_LEASE_MS,
40
+ DEFAULT_REACTION_MAX_ATTEMPTS,
41
+ DEFAULT_REACTION_MAX_BACKOFF_MS,
42
+ DEFAULT_REACTION_RETENTION,
43
+ MAX_REACTION_FAILURE_DETAILS_BYTES,
44
+ MAX_REACTION_PAYLOAD_BYTES,
45
+ MAX_REACTIONS_PER_COMMIT,
46
+ PermanentReactionError,
47
+ pruneReactions,
48
+ ReactionRunner,
49
+ reactionIdempotencyKey,
50
+ retryDeadLetterReaction,
51
+ RetryableReactionError,
52
+ type PlannedReaction,
53
+ type PruneReactionsOptions,
54
+ type ReactionHandler,
55
+ type ReactionHandlerInput,
56
+ type ReactionHandlers,
57
+ type ReactionPlan,
58
+ type ReactionPlanner,
59
+ type ReactionPlannerInput,
60
+ type ReactionPruneResult,
61
+ type ReactionRetentionPolicy,
62
+ type ReactionRunnerOptions,
63
+ type ReactionRunResult,
64
+ type ReactionTypeMap,
65
+ } from './reactions';
34
66
  export * from './realtime';
35
67
  export * from './relational-rows';
36
68
  export * from './s3-blob-store';
@@ -0,0 +1,272 @@
1
+ import {
2
+ decodeRemoteOperationRealtimeMessage,
3
+ encodeRemoteOperationRealtimeMessage,
4
+ type RemoteOperationRealtimeMessage,
5
+ } from '@syncular/core';
6
+ import type { RealtimeNotifier, SyncRequestContext } from './context';
7
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context';
8
+ import { SyncError, syncError } from './errors';
9
+ import type {
10
+ RegisteredRemoteQuery,
11
+ RemoteOperationRegistry,
12
+ } from './operations';
13
+ import type { StoredCommit } from './storage';
14
+
15
+ export interface RemoteOperationWatchSession {
16
+ receive(bytes: Uint8Array): Promise<void>;
17
+ close(): void;
18
+ }
19
+
20
+ interface WatchState {
21
+ readonly watchId: string;
22
+ readonly clientId: string;
23
+ readonly operation: RegisteredRemoteQuery;
24
+ readonly params: unknown;
25
+ running: boolean;
26
+ dirty: boolean;
27
+ }
28
+
29
+ class WatchSession implements RemoteOperationWatchSession {
30
+ readonly #ctx: SyncRequestContext;
31
+ readonly #registry: RemoteOperationRegistry;
32
+ readonly #send: (bytes: Uint8Array) => void;
33
+ readonly #closed: () => void;
34
+ readonly #watches = new Map<string, WatchState>();
35
+ #isClosed = false;
36
+
37
+ get partition(): string {
38
+ return this.#ctx.partition;
39
+ }
40
+
41
+ constructor(
42
+ ctx: SyncRequestContext,
43
+ registry: RemoteOperationRegistry,
44
+ send: (bytes: Uint8Array) => void,
45
+ closed: () => void,
46
+ ) {
47
+ this.#ctx = ctx;
48
+ this.#registry = registry;
49
+ this.#send = send;
50
+ this.#closed = closed;
51
+ }
52
+
53
+ async receive(bytes: Uint8Array): Promise<void> {
54
+ if (this.#isClosed) return;
55
+ let message: RemoteOperationRealtimeMessage;
56
+ try {
57
+ message = decodeRemoteOperationRealtimeMessage(bytes);
58
+ if (
59
+ typeof message !== 'object' ||
60
+ message === null ||
61
+ message.revision !== 1 ||
62
+ (message.kind !== 'watch' && message.kind !== 'unwatch') ||
63
+ typeof message.watchId !== 'string' ||
64
+ message.watchId.length === 0
65
+ ) {
66
+ throw syncError('operation.invalid_request');
67
+ }
68
+ if (message.kind === 'watch') {
69
+ if (
70
+ typeof message.clientId !== 'string' ||
71
+ message.clientId.length === 0 ||
72
+ typeof message.operationId !== 'string' ||
73
+ message.operationId.length === 0
74
+ ) {
75
+ throw syncError('operation.invalid_request');
76
+ }
77
+ }
78
+ } catch (error) {
79
+ if (error instanceof SyncError) throw error;
80
+ throw syncError('operation.invalid_request');
81
+ }
82
+ if (message.kind === 'unwatch') {
83
+ this.#watches.delete(message.watchId);
84
+ return;
85
+ }
86
+ if (this.#watches.has(message.watchId)) {
87
+ this.#sendError(
88
+ message.watchId,
89
+ syncError('operation.invalid_request', 'watchId is already active'),
90
+ );
91
+ return;
92
+ }
93
+ if (message.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
94
+ this.#sendError(
95
+ message.watchId,
96
+ syncError(
97
+ 'sync.invalid_client_id',
98
+ 'clientId uses a reserved server-command namespace (§1.5)',
99
+ ),
100
+ );
101
+ return;
102
+ }
103
+ const clientRecord = await this.#ctx.storage.getClientRecord(
104
+ this.#ctx.partition,
105
+ message.clientId,
106
+ );
107
+ if (
108
+ clientRecord !== undefined &&
109
+ clientRecord.actorId !== this.#ctx.actorId
110
+ ) {
111
+ this.#sendError(
112
+ message.watchId,
113
+ syncError(
114
+ 'sync.invalid_client_id',
115
+ 'clientId is bound to a different actor in this partition (§1.5)',
116
+ ),
117
+ );
118
+ return;
119
+ }
120
+ const operation = this.#registry.get(message.operationId);
121
+ if (operation === undefined || operation.kind !== 'query') {
122
+ this.#sendError(message.watchId, syncError('operation.unknown'));
123
+ return;
124
+ }
125
+ const state: WatchState = {
126
+ watchId: message.watchId,
127
+ clientId: message.clientId,
128
+ operation,
129
+ params: message.params,
130
+ running: false,
131
+ dirty: false,
132
+ };
133
+ this.#watches.set(message.watchId, state);
134
+ await this.#refresh(state);
135
+ }
136
+
137
+ notify(tables: ReadonlySet<string>): void {
138
+ for (const state of this.#watches.values()) {
139
+ if (!state.operation.tables.some((table) => tables.has(table))) continue;
140
+ if (state.running) state.dirty = true;
141
+ else void this.#refresh(state);
142
+ }
143
+ }
144
+
145
+ async #refresh(state: WatchState): Promise<void> {
146
+ if (this.#isClosed || this.#watches.get(state.watchId) !== state) return;
147
+ if (state.running) {
148
+ state.dirty = true;
149
+ return;
150
+ }
151
+ state.running = true;
152
+ try {
153
+ do {
154
+ state.dirty = false;
155
+ try {
156
+ const response = await state.operation.run(
157
+ this.#ctx,
158
+ state.clientId,
159
+ state.params,
160
+ );
161
+ if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
162
+ return;
163
+ }
164
+ if (response.kind !== 'query') {
165
+ throw syncError('operation.query_failed');
166
+ }
167
+ if (
168
+ !this.#emit(
169
+ encodeRemoteOperationRealtimeMessage({
170
+ revision: 1,
171
+ kind: 'snapshot',
172
+ watchId: state.watchId,
173
+ operationId: response.operationId,
174
+ rows: response.rows,
175
+ maxCommitSeq: response.maxCommitSeq,
176
+ }),
177
+ )
178
+ )
179
+ return;
180
+ } catch (error) {
181
+ if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
182
+ return;
183
+ }
184
+ this.#sendError(
185
+ state.watchId,
186
+ error instanceof SyncError
187
+ ? error
188
+ : syncError('operation.query_failed'),
189
+ );
190
+ }
191
+ } while (
192
+ state.dirty &&
193
+ !this.#isClosed &&
194
+ this.#watches.get(state.watchId) === state
195
+ );
196
+ } finally {
197
+ state.running = false;
198
+ }
199
+ }
200
+
201
+ #sendError(watchId: string, error: SyncError): void {
202
+ this.#emit(
203
+ encodeRemoteOperationRealtimeMessage({
204
+ revision: 1,
205
+ kind: 'watch_error',
206
+ watchId,
207
+ code: error.code,
208
+ message: error.message,
209
+ retryable: error.retryable,
210
+ }),
211
+ );
212
+ }
213
+
214
+ #emit(bytes: Uint8Array): boolean {
215
+ if (this.#isClosed) return false;
216
+ try {
217
+ this.#send(bytes);
218
+ return true;
219
+ } catch {
220
+ this.close();
221
+ return false;
222
+ }
223
+ }
224
+
225
+ close(): void {
226
+ if (this.#isClosed) return;
227
+ this.#isClosed = true;
228
+ this.#watches.clear();
229
+ this.#closed();
230
+ }
231
+ }
232
+
233
+ /** In-memory invalidation hub. Every notification reruns affected watches. */
234
+ export class RemoteOperationWatchHub implements RealtimeNotifier {
235
+ readonly #registry: RemoteOperationRegistry;
236
+ readonly #sessions = new Set<WatchSession>();
237
+
238
+ constructor(registry: RemoteOperationRegistry) {
239
+ this.#registry = registry;
240
+ }
241
+
242
+ connect(
243
+ ctx: SyncRequestContext,
244
+ send: (bytes: Uint8Array) => void,
245
+ ): RemoteOperationWatchSession {
246
+ const session = new WatchSession(ctx, this.#registry, send, () => {
247
+ this.#sessions.delete(session);
248
+ });
249
+ this.#sessions.add(session);
250
+ return session;
251
+ }
252
+
253
+ notifyCommit(partition: string, commit: StoredCommit): void {
254
+ const tables = new Set(commit.changes.map((change) => change.table));
255
+ for (const session of this.#sessions) {
256
+ if (session.partition === partition) session.notify(tables);
257
+ }
258
+ }
259
+ }
260
+
261
+ /** Fan one applied commit into sync deltas and registered query watches. */
262
+ export function composeRealtimeNotifiers(
263
+ ...notifiers: readonly RealtimeNotifier[]
264
+ ): RealtimeNotifier {
265
+ return {
266
+ notifyCommit: async (partition, commit) => {
267
+ await Promise.all(
268
+ notifiers.map((notifier) => notifier.notifyCommit(partition, commit)),
269
+ );
270
+ },
271
+ };
272
+ }