alchemy 0.92.2 → 0.93.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/bin/alchemy.js +2 -2
  2. package/lib/cloudflare/ai-search-namespace.d.ts +150 -0
  3. package/lib/cloudflare/ai-search-namespace.d.ts.map +1 -0
  4. package/lib/cloudflare/ai-search-namespace.js +278 -0
  5. package/lib/cloudflare/ai-search-namespace.js.map +1 -0
  6. package/lib/cloudflare/ai-search-token.d.ts +11 -1
  7. package/lib/cloudflare/ai-search-token.d.ts.map +1 -1
  8. package/lib/cloudflare/ai-search-token.js +35 -7
  9. package/lib/cloudflare/ai-search-token.js.map +1 -1
  10. package/lib/cloudflare/ai-search.d.ts +123 -18
  11. package/lib/cloudflare/ai-search.d.ts.map +1 -1
  12. package/lib/cloudflare/ai-search.js +222 -68
  13. package/lib/cloudflare/ai-search.js.map +1 -1
  14. package/lib/cloudflare/bindings.d.ts +29 -2
  15. package/lib/cloudflare/bindings.d.ts.map +1 -1
  16. package/lib/cloudflare/bindings.js.map +1 -1
  17. package/lib/cloudflare/bound.d.ts +4 -2
  18. package/lib/cloudflare/bound.d.ts.map +1 -1
  19. package/lib/cloudflare/container.d.ts +79 -6
  20. package/lib/cloudflare/container.d.ts.map +1 -1
  21. package/lib/cloudflare/container.js +130 -9
  22. package/lib/cloudflare/container.js.map +1 -1
  23. package/lib/cloudflare/custom-domain.d.ts +14 -0
  24. package/lib/cloudflare/custom-domain.d.ts.map +1 -1
  25. package/lib/cloudflare/custom-domain.js +3 -0
  26. package/lib/cloudflare/custom-domain.js.map +1 -1
  27. package/lib/cloudflare/index.d.ts +1 -0
  28. package/lib/cloudflare/index.d.ts.map +1 -1
  29. package/lib/cloudflare/index.js +1 -0
  30. package/lib/cloudflare/index.js.map +1 -1
  31. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
  32. package/lib/cloudflare/miniflare/build-worker-options.js +43 -0
  33. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
  34. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts.map +1 -1
  35. package/lib/cloudflare/miniflare/remote-binding-proxy.js +31 -13
  36. package/lib/cloudflare/miniflare/remote-binding-proxy.js.map +1 -1
  37. package/lib/cloudflare/website.d.ts +4 -0
  38. package/lib/cloudflare/website.d.ts.map +1 -1
  39. package/lib/cloudflare/website.js +15 -1
  40. package/lib/cloudflare/website.js.map +1 -1
  41. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  42. package/lib/cloudflare/worker-metadata.js +25 -1
  43. package/lib/cloudflare/worker-metadata.js.map +1 -1
  44. package/lib/cloudflare/worker.d.ts +6 -0
  45. package/lib/cloudflare/worker.d.ts.map +1 -1
  46. package/lib/cloudflare/worker.js +3 -0
  47. package/lib/cloudflare/worker.js.map +1 -1
  48. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  49. package/lib/cloudflare/wrangler.json.js +32 -0
  50. package/lib/cloudflare/wrangler.json.js.map +1 -1
  51. package/lib/docker/api.d.ts +4 -1
  52. package/lib/docker/api.d.ts.map +1 -1
  53. package/lib/docker/api.js +8 -2
  54. package/lib/docker/api.js.map +1 -1
  55. package/lib/docker/image.d.ts +1 -1
  56. package/lib/docker/image.d.ts.map +1 -1
  57. package/lib/docker/image.js +4 -44
  58. package/lib/docker/image.js.map +1 -1
  59. package/lib/docker/registry.d.ts +11 -0
  60. package/lib/docker/registry.d.ts.map +1 -0
  61. package/lib/docker/registry.js +41 -0
  62. package/lib/docker/registry.js.map +1 -0
  63. package/package.json +3 -3
  64. package/src/cloudflare/ai-search-namespace.ts +435 -0
  65. package/src/cloudflare/ai-search-token.ts +43 -9
  66. package/src/cloudflare/ai-search.ts +334 -73
  67. package/src/cloudflare/bindings.ts +33 -0
  68. package/src/cloudflare/bound.ts +87 -74
  69. package/src/cloudflare/container.ts +242 -18
  70. package/src/cloudflare/custom-domain.ts +25 -1
  71. package/src/cloudflare/index.ts +1 -0
  72. package/src/cloudflare/miniflare/build-worker-options.ts +50 -1
  73. package/src/cloudflare/miniflare/remote-binding-proxy.ts +58 -34
  74. package/src/cloudflare/website.ts +19 -1
  75. package/src/cloudflare/worker-metadata.ts +25 -1
  76. package/src/cloudflare/worker.ts +9 -0
  77. package/src/cloudflare/wrangler.json.ts +32 -0
  78. package/src/docker/api.ts +11 -2
  79. package/src/docker/image.ts +5 -56
  80. package/src/docker/registry.ts +62 -0
  81. package/workers/cloudflare-state-store.js +57 -57
  82. package/workers/tunnel-proxy.js +1 -1
@@ -0,0 +1,435 @@
1
+ import type { Context } from "../context.ts";
2
+ import { Resource, ResourceKind } from "../resource.ts";
3
+ import { logger } from "../util/logger.ts";
4
+ import { poll } from "../util/poll.ts";
5
+ import { CloudflareApiError } from "./api-error.ts";
6
+ import { extractCloudflareResult } from "./api-response.ts";
7
+ import {
8
+ createCloudflareApi,
9
+ type CloudflareApi,
10
+ type CloudflareApiOptions,
11
+ } from "./api.ts";
12
+
13
+ /**
14
+ * Properties for creating or updating an AI Search Namespace
15
+ */
16
+ export interface AiSearchNamespaceProps extends CloudflareApiOptions {
17
+ /**
18
+ * Name of the namespace.
19
+ * Must match pattern: `^[a-z0-9]([a-z0-9-]{0,26}[a-z0-9])?$`
20
+ *
21
+ * @default `${app}-${stage}-${id}`
22
+ */
23
+ name?: string;
24
+
25
+ /**
26
+ * Optional description for the namespace (max 256 characters).
27
+ *
28
+ * Pass `null` to explicitly clear a previously-set description on update.
29
+ * Leaving this field undefined (absent from props) will NOT touch the
30
+ * existing description on update.
31
+ */
32
+ description?: string | null;
33
+
34
+ /**
35
+ * Whether to adopt an existing namespace with the same name if it exists
36
+ *
37
+ * @default false
38
+ */
39
+ adopt?: boolean;
40
+
41
+ /**
42
+ * Whether to delete the namespace when removed from Alchemy.
43
+ * Namespace must be empty (no instances) to be deleted.
44
+ *
45
+ * @default true
46
+ */
47
+ delete?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Type guard for AiSearchNamespace
52
+ */
53
+ export function isAiSearchNamespace(
54
+ resource: unknown,
55
+ ): resource is AiSearchNamespace {
56
+ return (
57
+ typeof resource === "object" &&
58
+ resource !== null &&
59
+ (resource as any)[ResourceKind] === "cloudflare::AiSearchNamespace"
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Output returned after AI Search Namespace creation/update
65
+ */
66
+ export type AiSearchNamespace = Omit<
67
+ AiSearchNamespaceProps,
68
+ "delete" | "adopt" | "description" | "name"
69
+ > & {
70
+ /**
71
+ * Resource type identifier for binding resolution
72
+ */
73
+ type: "ai_search_namespace";
74
+
75
+ /**
76
+ * The stable identifier of the namespace (equal to `namespace`).
77
+ * Present because Alchemy's resource model expects every output to carry an `id`.
78
+ */
79
+ id: string;
80
+
81
+ /**
82
+ * The name of the namespace.
83
+ */
84
+ namespace: string;
85
+
86
+ /**
87
+ * Optional description of the namespace
88
+ */
89
+ description: string | null;
90
+
91
+ /**
92
+ * Time at which the namespace was created
93
+ */
94
+ createdAt: string;
95
+ };
96
+
97
+ /**
98
+ * API response for AI Search namespace
99
+ * @internal
100
+ */
101
+ interface AiSearchNamespaceApiResponse {
102
+ name: string;
103
+ description: string | null;
104
+ created_at: string;
105
+ }
106
+
107
+ /**
108
+ * An AI Search Namespace groups AI Search instances together,
109
+ * providing logical isolation and scoped access control.
110
+ *
111
+ * Namespaces are a first-class concept in the AI Search binding model.
112
+ * Instance names are unique within their namespace: `UNIQUE(account_id, namespace, name)`.
113
+ *
114
+ * @see https://developers.cloudflare.com/ai-search/
115
+ *
116
+ * @example
117
+ * ## Create a namespace
118
+ *
119
+ * ```ts
120
+ * const ns = await AiSearchNamespace("production", {
121
+ * name: "production",
122
+ * });
123
+ * ```
124
+ *
125
+ * @example
126
+ * ## Use as a Worker binding
127
+ *
128
+ * ```ts
129
+ * const ns = await AiSearchNamespace("docs", {
130
+ * name: "docs",
131
+ * });
132
+ *
133
+ * const worker = await Worker("my-worker", {
134
+ * bindings: {
135
+ * DOCS: ns,
136
+ * },
137
+ * // ...
138
+ * });
139
+ * ```
140
+ *
141
+ * @example
142
+ * ## Adopt an existing namespace
143
+ *
144
+ * ```ts
145
+ * const ns = await AiSearchNamespace("existing", {
146
+ * name: "production",
147
+ * adopt: true,
148
+ * });
149
+ * ```
150
+ */
151
+ export const AiSearchNamespace = Resource(
152
+ "cloudflare::AiSearchNamespace",
153
+ async function (
154
+ this: Context<AiSearchNamespace>,
155
+ id: string,
156
+ props: AiSearchNamespaceProps = {},
157
+ ): Promise<AiSearchNamespace> {
158
+ const adopt = props.adopt ?? this.scope.adopt;
159
+
160
+ const namespace =
161
+ props.name ??
162
+ this.output?.namespace ??
163
+ this.scope.createPhysicalName(id, "-", 28);
164
+
165
+ // Validate namespace name against CF's documented pattern. We do this
166
+ // before any API calls so users get a clear local error rather than a
167
+ // generic API 400. Also guards against `createPhysicalName` truncation
168
+ // producing a name that begins/ends with "-".
169
+ //
170
+ // Skip validation during delete: `this.output?.namespace` is always a
171
+ // previously-accepted name (came from `toOutput(result)`), so there's
172
+ // nothing to re-validate. Critically, this also prevents a failed
173
+ // create (which throws from this guard without persisting state) from
174
+ // tripping the same guard during scope teardown — destroy would have
175
+ // nothing to delete anyway.
176
+ if (
177
+ this.phase !== "delete" &&
178
+ !/^[a-z0-9]([a-z0-9-]{0,26}[a-z0-9])?$/.test(namespace)
179
+ ) {
180
+ throw new Error(
181
+ `AI Search namespace name "${namespace}" is invalid. ` +
182
+ "Must match pattern: ^[a-z0-9]([a-z0-9-]{0,26}[a-z0-9])?$ " +
183
+ "(1-28 chars, lowercase alphanumerics and hyphens, cannot start or end with a hyphen).",
184
+ );
185
+ }
186
+
187
+ // The `default` namespace is reserved by Cloudflare — it is created
188
+ // automatically on every account and cannot be created, updated, or
189
+ // deleted via the API. Only allow binding to it via `adopt: true`.
190
+ if (namespace === "default" && !adopt && this.phase !== "delete") {
191
+ throw new Error(
192
+ 'The "default" AI Search namespace is reserved by Cloudflare. ' +
193
+ "Use `adopt: true` to bind to it (it cannot be created, renamed, or deleted via the API).",
194
+ );
195
+ }
196
+
197
+ // NOTE: AI Search is an always-remote binding (no Miniflare-native
198
+ // implementation). `alchemy dev` wires the worker binding via
199
+ // `remote-binding-proxy`, which requires the namespace to actually exist
200
+ // on Cloudflare at preview-token creation time — a locally mocked
201
+ // resource would cause the Worker deploy to fail with error 10359
202
+ // ("namespace … does not exist"). Follow the same pattern as Vectorize
203
+ // and skip the `scope.local` mock branch entirely.
204
+ const api = await createCloudflareApi(props);
205
+
206
+ if (this.phase === "delete") {
207
+ const namespaceName = this.output?.namespace;
208
+ if (namespaceName && props.delete !== false) {
209
+ await deleteAiSearchNamespace(api, namespaceName);
210
+ }
211
+ return this.destroy();
212
+ }
213
+
214
+ // Immutable name — replace if changed. We use the default
215
+ // (pending-deletion) replace strategy: the old namespace is queued for
216
+ // deletion and flushed at scope finalize, matching the convention used
217
+ // by other Cloudflare resources in this repo. Forcing immediate
218
+ // deletion (`replace(true)`) interacts poorly with Cloudflare's
219
+ // namespace-delete consistency window and causes the old namespace to
220
+ // still appear in subsequent GETs.
221
+ if (this.phase === "update" && this.output?.namespace !== namespace) {
222
+ return this.replace();
223
+ }
224
+
225
+ if (this.phase === "update" && this.output) {
226
+ // Three-way semantics on `description`:
227
+ // - absent (undefined) → do not touch (preserves out-of-band edits)
228
+ // - string → set to that value
229
+ // - null → clear (explicit opt-in to clearing)
230
+ // We only PUT when the value differs from current state.
231
+ const hasDescriptionProp = "description" in props;
232
+ if (hasDescriptionProp && props.description !== this.output.description) {
233
+ const updated = await updateAiSearchNamespace(api, namespace, {
234
+ description: props.description,
235
+ });
236
+ return toOutput(updated);
237
+ }
238
+ return this.output;
239
+ }
240
+
241
+ // Adopting the reserved `default` namespace is a no-op creation: emit a
242
+ // warn so the behavior is visible (delete is silently skipped later).
243
+ if (namespace === "default" && adopt) {
244
+ logger.warn(
245
+ `Adopting the reserved AI Search "default" namespace. It will not be deleted on teardown.`,
246
+ );
247
+ }
248
+
249
+ // Create (with adopt via pre-flight check when enabled)
250
+ const hasDescriptionProp = "description" in props;
251
+ let result: AiSearchNamespaceApiResponse;
252
+ if (adopt) {
253
+ // Pre-flight: if namespace exists, adopt it; otherwise create.
254
+ // `getAiSearchNamespace` internally returns `undefined` on 404 and
255
+ // rethrows all other errors (auth, 5xx, network) — no catch needed here.
256
+ const existing = await getAiSearchNamespace(api, namespace);
257
+ if (existing) {
258
+ if (hasDescriptionProp && props.description !== existing.description) {
259
+ result = await updateAiSearchNamespace(api, namespace, {
260
+ description: props.description,
261
+ });
262
+ } else {
263
+ result = existing;
264
+ }
265
+ } else if (namespace === "default") {
266
+ // The `default` namespace is implicit — treat GET 404 as "exists but
267
+ // not queryable" and return a synthetic adoption record. Use the
268
+ // current timestamp rather than epoch 0 to avoid a misleading
269
+ // "1970-01-01" createdAt in the state file.
270
+ result = {
271
+ name: "default",
272
+ description: null,
273
+ created_at: new Date().toISOString(),
274
+ };
275
+ } else {
276
+ result = await createAiSearchNamespace(api, {
277
+ name: namespace,
278
+ description: props.description ?? undefined,
279
+ });
280
+ }
281
+ } else {
282
+ try {
283
+ result = await createAiSearchNamespace(api, {
284
+ name: namespace,
285
+ description: props.description ?? undefined,
286
+ });
287
+ } catch (error) {
288
+ // Wrap "already exists" errors with AGENTS.md-style messaging that
289
+ // points at `adopt: true`.
290
+ if (
291
+ error instanceof CloudflareApiError &&
292
+ (error.status === 400 || error.status === 409)
293
+ ) {
294
+ const existing = await getAiSearchNamespace(api, namespace);
295
+ if (existing) {
296
+ throw new Error(
297
+ `AI Search namespace "${namespace}" already exists. Use \`adopt: true\` to adopt it.`,
298
+ { cause: error },
299
+ );
300
+ }
301
+ }
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ return toOutput(result);
307
+ },
308
+ );
309
+
310
+ function toOutput(result: AiSearchNamespaceApiResponse): AiSearchNamespace {
311
+ return {
312
+ type: "ai_search_namespace",
313
+ id: result.name,
314
+ namespace: result.name,
315
+ description: result.description ?? null,
316
+ createdAt: result.created_at,
317
+ };
318
+ }
319
+
320
+ // ─── API Helper Functions ────────────────────────────────────────────────────
321
+
322
+ /**
323
+ * Create an AI Search namespace
324
+ */
325
+ export async function createAiSearchNamespace(
326
+ api: CloudflareApi,
327
+ payload: { name: string; description?: string | null },
328
+ ): Promise<AiSearchNamespaceApiResponse> {
329
+ return await extractCloudflareResult<AiSearchNamespaceApiResponse>(
330
+ `create AI Search namespace "${payload.name}"`,
331
+ api.post(`/accounts/${api.accountId}/ai-search/namespaces`, payload),
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Get an AI Search namespace by name
337
+ */
338
+ export async function getAiSearchNamespace(
339
+ api: CloudflareApi,
340
+ name: string,
341
+ ): Promise<AiSearchNamespaceApiResponse | undefined> {
342
+ try {
343
+ return await extractCloudflareResult<AiSearchNamespaceApiResponse>(
344
+ `get AI Search namespace "${name}"`,
345
+ api.get(`/accounts/${api.accountId}/ai-search/namespaces/${name}`),
346
+ );
347
+ } catch (error) {
348
+ if (error instanceof CloudflareApiError && error.status === 404) {
349
+ return undefined;
350
+ }
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Update an AI Search namespace.
357
+ *
358
+ * `description: null` clears a previously-set description. `description`
359
+ * absent from the payload leaves the current value untouched.
360
+ */
361
+ export async function updateAiSearchNamespace(
362
+ api: CloudflareApi,
363
+ name: string,
364
+ payload: { description?: string | null },
365
+ ): Promise<AiSearchNamespaceApiResponse> {
366
+ return await extractCloudflareResult<AiSearchNamespaceApiResponse>(
367
+ `update AI Search namespace "${name}"`,
368
+ api.put(`/accounts/${api.accountId}/ai-search/namespaces/${name}`, payload),
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Delete an AI Search namespace. Namespace must be empty (no instances).
374
+ * The reserved `default` namespace cannot be deleted and is skipped.
375
+ */
376
+ export async function deleteAiSearchNamespace(
377
+ api: CloudflareApi,
378
+ name: string,
379
+ ): Promise<void> {
380
+ // The `default` namespace is reserved and cannot be deleted by the API.
381
+ // Skip silently to allow clean teardown of resources that adopted it.
382
+ if (name === "default") {
383
+ return;
384
+ }
385
+ try {
386
+ await extractCloudflareResult(
387
+ `delete AI Search namespace "${name}"`,
388
+ api.delete(`/accounts/${api.accountId}/ai-search/namespaces/${name}`),
389
+ );
390
+ } catch (error) {
391
+ if (error instanceof CloudflareApiError) {
392
+ if (error.status === 404) {
393
+ return;
394
+ }
395
+ // Namespace is likely non-empty — wrap with a clearer message.
396
+ if (error.status === 400 || error.status === 409) {
397
+ throw new Error(
398
+ `Cannot delete AI Search namespace "${name}". ` +
399
+ "The namespace may contain instances — delete all instances first, " +
400
+ "or use { delete: false } on the AiSearchNamespace resource to preserve it.",
401
+ { cause: error },
402
+ );
403
+ }
404
+ }
405
+ throw error;
406
+ }
407
+
408
+ // Cloudflare's namespace DELETE is eventually consistent — same-colo
409
+ // GETs are invalidated immediately via the edge cache, cross-colo GETs
410
+ // can surface the stale row for up to 60s (KV TTL). Wait until the
411
+ // namespace is truly gone so destroy phase presents a strongly-
412
+ // consistent contract to callers (user teardown assertions,
413
+ // downstream resources, rename-triggered replace flows).
414
+ await poll({
415
+ description: `wait for AI Search namespace "${name}" deletion to propagate`,
416
+ fn: () =>
417
+ api.get(`/accounts/${api.accountId}/ai-search/namespaces/${name}`),
418
+ predicate: (res) => res.status === 404,
419
+ initialDelay: 500,
420
+ maxDelay: 3000,
421
+ timeout: 90_000,
422
+ });
423
+ }
424
+
425
+ /**
426
+ * List all AI Search namespaces in an account
427
+ */
428
+ export async function listAiSearchNamespaces(
429
+ api: CloudflareApi,
430
+ ): Promise<AiSearchNamespaceApiResponse[]> {
431
+ return await extractCloudflareResult<AiSearchNamespaceApiResponse[]>(
432
+ "list AI Search namespaces",
433
+ api.get(`/accounts/${api.accountId}/ai-search/namespaces`),
434
+ );
435
+ }
@@ -291,22 +291,56 @@ export async function listAiSearchTokens(
291
291
  }
292
292
 
293
293
  /**
294
- * Delete an AI Search token
294
+ * Delete an AI Search token.
295
+ *
296
+ * Retries briefly on `7076 token_in_use_by_instances` to cover residual
297
+ * cross-colo lag (CF's server retries the guard internally once after
298
+ * 500ms; the spurious-409 window is typically sub-second). We layer two
299
+ * client-side retries with 1s + 2s delays on top.
300
+ *
301
+ * A persistent 409 after the short retry window means the token is
302
+ * genuinely still referenced by another AI Search instance. We throw an
303
+ * actionable error rather than silently leaking the token — shared tokens
304
+ * should be modeled explicitly via an `AiSearchToken` resource.
295
305
  */
296
306
  export async function deleteAiSearchToken(
297
307
  api: CloudflareApi,
298
308
  tokenId: string,
299
309
  ): Promise<void> {
300
- try {
301
- await extractCloudflareResult(
302
- `delete AI Search token "${tokenId}"`,
303
- api.delete(`/accounts/${api.accountId}/ai-search/tokens/${tokenId}`),
304
- );
305
- } catch (error) {
306
- if (error instanceof CloudflareApiError && error.status === 404) {
310
+ const retryDelaysMs = [1000, 2000]; // 2 retries, ~3s total
311
+ for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
312
+ try {
313
+ await extractCloudflareResult(
314
+ `delete AI Search token "${tokenId}"`,
315
+ api.delete(`/accounts/${api.accountId}/ai-search/tokens/${tokenId}`),
316
+ );
307
317
  return;
318
+ } catch (error) {
319
+ if (error instanceof CloudflareApiError && error.status === 404) {
320
+ return;
321
+ }
322
+ const isTokenInUse =
323
+ error instanceof CloudflareApiError &&
324
+ error.status === 409 &&
325
+ Array.isArray(error.errorData) &&
326
+ error.errorData.some((e: { code?: number }) => e?.code === 7076);
327
+ if (isTokenInUse && attempt < retryDelaysMs.length) {
328
+ await new Promise((resolve) =>
329
+ setTimeout(resolve, retryDelaysMs[attempt]),
330
+ );
331
+ continue;
332
+ }
333
+ if (isTokenInUse) {
334
+ throw new Error(
335
+ `AI Search token "${tokenId}" is still referenced by another instance. ` +
336
+ "If the token is intentionally shared across instances, manage it " +
337
+ "explicitly via an `AiSearchToken` resource with `delete: false`, " +
338
+ "or delete all referencing instances before deleting the token.",
339
+ { cause: error },
340
+ );
341
+ }
342
+ throw error;
308
343
  }
309
- throw error;
310
344
  }
311
345
  }
312
346