@ai-matrx/records 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog — @ai-matrx/records
2
2
 
3
+ ## 0.45.0
4
+
5
+ **One coercion primitive: what a typed-in answer means to a Field, in one place.**
6
+
7
+ AGENT-BUILDS-2's closing walk, 2026-09-21, found Ironclad Mobile Mechanic's public booking
8
+ page **unbookable over any table with a number field**. A customer picked 2:00 PM, typed a
9
+ 2019 Honda CR-V, pressed "Book it" and read:
10
+
11
+ > Vehicle Year takes a number, and it was given a string
12
+
13
+ and got no appointment. A browser input hands over a STRING; a Field takes a value. The
14
+ grid's paste and the CSV import each carried their own coercion — the two doors a STRANGER
15
+ uses carried none.
16
+
17
+ New, headless, and now the only body that answers the question:
18
+
19
+ - **`fieldKindFor(field, kernel)`** — the ONE classifier (behaviour, modifiers, format, unit,
20
+ relation target, config). `@ai-matrx/records-ui`'s `editorKindFor` is this function with
21
+ the kernel File/Person ids bound, so the editor a person types into and the coercion
22
+ applied to what they typed cannot disagree about a column.
23
+ - **`coerceTypedAnswer(field, raw, opts)`** — one string → the Field's value, or a refusal
24
+ naming the field **in plain words**, never its key. It coerces and never guesses: `0` for
25
+ "about twelve" is how a table quietly becomes untrue.
26
+ - **`coerceTypedAnswers(fields, answers, opts)`** — a whole screen at once. Values already
27
+ of the right shape are left alone; keys the table has no Field for are REPORTED rather
28
+ than dropped, because naming an unknown key is the store's sentence to say.
29
+ - **`CoercibleField`** — the structural shape the primitive needs, so `CaptureField` and the
30
+ Field documents `custom.form_public` / `custom.booking_public` publish to a stranger all
31
+ satisfy it. Demanding a whole `Field` is how the capture sheet kept its own `Number(raw)`.
32
+ - **`FIELD_KINDS`** — the runtime list `FieldKind` is derived from, so a coverage test walks
33
+ the source of truth rather than a retyped copy.
34
+ - **`ChoiceOptions`** moved here from records-ui, which re-exports it.
35
+
36
+ *Consumer action:* none required — everything is additive. A host that coerces typed-in text
37
+ itself should delete that code and call `coerceTypedAnswer`.
38
+
3
39
  ## 0.43.0
4
40
 
5
41
  **The realtime port stops being half a contract, and a nudge re-reads only what moved.**
@@ -40,6 +40,7 @@ __export(core_exports, {
40
40
  DELIVERIES: () => DELIVERIES,
41
41
  DOORS: () => DOORS,
42
42
  ENTITY_TOKENS: () => ENTITY_TOKENS,
43
+ FIELD_KINDS: () => FIELD_KINDS,
43
44
  FIELD_SENSITIVITIES: () => FIELD_SENSITIVITIES,
44
45
  FIELD_SOURCES: () => FIELD_SOURCES,
45
46
  FRESHNESS: () => FRESHNESS,
@@ -80,6 +81,9 @@ __export(core_exports, {
80
81
  choiceSlug: () => choiceSlug,
81
82
  choiceValuesOf: () => choiceValuesOf,
82
83
  choicesOf: () => choicesOf,
84
+ coerceTypedAnswer: () => coerceTypedAnswer,
85
+ coerceTypedAnswers: () => coerceTypedAnswers,
86
+ coercionLabel: () => coercionLabel,
83
87
  createRecordsClient: () => createRecordsClient,
84
88
  doorAbsent: () => doorAbsent,
85
89
  doorCensus: () => doorCensus,
@@ -87,6 +91,7 @@ __export(core_exports, {
87
91
  err: () => err,
88
92
  exportCsv: () => exportCsv,
89
93
  exportXlsx: () => exportXlsx,
94
+ fieldKindFor: () => fieldKindFor,
90
95
  forgetAllOps: () => forgetAllOps,
91
96
  highestLevel: () => highestLevel,
92
97
  holdsARetiredChoice: () => holdsARetiredChoice,
@@ -251,6 +256,209 @@ function looksLikeAnId(value) {
251
256
  return typeof value === "string" && UUID.test(value);
252
257
  }
253
258
 
259
+ // src/coerce.ts
260
+ var FIELD_KINDS = [
261
+ "attachment",
262
+ "checkbox",
263
+ "currency",
264
+ "datetime",
265
+ "email",
266
+ "formula",
267
+ "json",
268
+ "long_text",
269
+ "lookup",
270
+ "member",
271
+ "multi_select",
272
+ "number",
273
+ "percent",
274
+ "phone",
275
+ "relation",
276
+ "rollup",
277
+ "select",
278
+ "text",
279
+ "url"
280
+ ];
281
+ function fieldKindFor(field, kernel) {
282
+ const format = (field.format ?? "").toLowerCase();
283
+ const config = field.config ?? {};
284
+ switch (field.type) {
285
+ case "boolean":
286
+ return "checkbox";
287
+ case "text":
288
+ if (format === "email") return "email";
289
+ if (format === "phone") return "phone";
290
+ if (format === "url") return "url";
291
+ if (format === "json" || config["json"] === true) return "json";
292
+ if (format === "long" || config["multiline"] === true) return "long_text";
293
+ return "text";
294
+ case "range": {
295
+ const kind = String(config["kind"] ?? "number");
296
+ if (kind === "date" || kind === "datetime") return "datetime";
297
+ if (format === "currency") return "currency";
298
+ if (format === "percent" || field.unit === "%") return "percent";
299
+ return "number";
300
+ }
301
+ case "list":
302
+ return field.multi ? "multi_select" : "select";
303
+ case "relation": {
304
+ const parity = String(config["parity"] ?? "");
305
+ if (parity === "attachment" || parity === "member") return parity;
306
+ if (kernel?.file && field.relation_target === kernel.file) return "attachment";
307
+ if (kernel?.person && field.relation_target === kernel.person) return "member";
308
+ return "relation";
309
+ }
310
+ case "formula": {
311
+ const via = config["via"];
312
+ if (via && config["agg"]) return "rollup";
313
+ if (via) return "lookup";
314
+ return "formula";
315
+ }
316
+ default:
317
+ return "text";
318
+ }
319
+ }
320
+ var TRUE_WORDS = /* @__PURE__ */ new Set(["true", "yes", "y", "1", "\u2713", "x", "checked", "on"]);
321
+ var FALSE_WORDS = /* @__PURE__ */ new Set(["false", "no", "n", "0", "", "unchecked", "off"]);
322
+ function coercionLabel(field) {
323
+ const label = (field.label ?? "").trim();
324
+ if (label !== "") return label;
325
+ const name = (field.name ?? "").trim();
326
+ if (name !== "") return name;
327
+ return field.key.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
328
+ }
329
+ function coerceTypedAnswer(field, raw, opts = {}) {
330
+ const label = opts.label ?? coercionLabel(field);
331
+ const text = raw.trim();
332
+ const kind = fieldKindFor(field, opts.kernel);
333
+ if (text === "" && kind !== "checkbox") return { value: null };
334
+ switch (kind) {
335
+ case "checkbox": {
336
+ const word = text.toLowerCase();
337
+ if (TRUE_WORDS.has(word)) return { value: true };
338
+ if (FALSE_WORDS.has(word)) return { value: word === "" ? null : false };
339
+ return {
340
+ refusal: `${label} is a tick box, so it takes yes or no \u2014 \u201C${raw}\u201D is neither. Write TRUE/FALSE, yes/no or 1/0.`
341
+ };
342
+ }
343
+ case "number":
344
+ case "currency":
345
+ case "percent": {
346
+ const negative = /^\(.*\)$/.test(text);
347
+ const bare = text.replace(/^\(|\)$/g, "").replace(/[\s,]/g, "").replace(/^[^\d.\-+]+/, "").replace(/[^\d.\-+eE]+$/, "");
348
+ const n = Number(bare);
349
+ if (bare === "" || Number.isNaN(n)) {
350
+ return {
351
+ refusal: `${label} holds a number, and \u201C${raw}\u201D is not one. Write digits \u2014 a currency symbol, a percent sign, commas and brackets for a negative are all fine.`
352
+ };
353
+ }
354
+ return { value: negative ? -n : n };
355
+ }
356
+ case "datetime": {
357
+ const wantsTime = String(field.config?.["kind"] ?? "date") === "datetime";
358
+ const plainDay = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(raw);
359
+ if (!wantsTime && plainDay) return { value: `${plainDay[1]}-${plainDay[2]}-${plainDay[3]}` };
360
+ const at = Date.parse(text);
361
+ if (Number.isNaN(at)) {
362
+ return {
363
+ refusal: `${label} holds a date, and \u201C${raw}\u201D is not one this can read. Write it as 2026-09-21, or the way your spreadsheet writes dates.`
364
+ };
365
+ }
366
+ const parsed = new Date(at);
367
+ if (wantsTime) return { value: parsed.toISOString() };
368
+ const y = parsed.getFullYear();
369
+ const m = String(parsed.getMonth() + 1).padStart(2, "0");
370
+ const d = String(parsed.getDate()).padStart(2, "0");
371
+ return { value: `${y}-${m}-${d}` };
372
+ }
373
+ case "json": {
374
+ try {
375
+ return { value: JSON.parse(text) };
376
+ } catch {
377
+ return {
378
+ refusal: `${label} holds a document, and \u201C${raw}\u201D is not valid JSON. Write the whole object, braces and all.`
379
+ };
380
+ }
381
+ }
382
+ case "select":
383
+ case "multi_select": {
384
+ if (!opts.options) {
385
+ return {
386
+ refusal: `${label} is a list and its options have not been read yet, so nothing can be matched to \u201C${raw}\u201D. Try again once the column's choices have loaded.`
387
+ };
388
+ }
389
+ const wanted = kind === "multi_select" ? text.split(/[,;]/).map((w) => w.trim()).filter((w) => w.length > 0) : [text];
390
+ if (wanted.length === 0) return { value: kind === "multi_select" ? [] : null };
391
+ const matched = [];
392
+ const unmatched = [];
393
+ for (const word of wanted) {
394
+ const hit = opts.options.byWord.get(word.toLowerCase());
395
+ if (hit === void 0) unmatched.push(word);
396
+ else matched.push(hit);
397
+ }
398
+ if (unmatched.length > 0) {
399
+ const offered = opts.options.labels.length > 0 ? opts.options.labels.join(", ") : "it has none yet";
400
+ return {
401
+ refusal: `${label} does not have ${unmatched.map((w) => `\u201C${w}\u201D`).join(" or ")} among its options. It offers: ${offered}.`,
402
+ unmatched
403
+ };
404
+ }
405
+ return { value: kind === "multi_select" ? matched : matched[0] };
406
+ }
407
+ // AN IDENTITY IS NOT A WORD. Writing the word would put a string where an
408
+ // id belongs, and every filter, colour and rollup built on that column
409
+ // would stop agreeing with it. So it is refused and the person is told
410
+ // which control does hold it.
411
+ case "member":
412
+ return {
413
+ refusal: `${label} names a person in this organization, so it is chosen rather than typed. Open it and pick them.`
414
+ };
415
+ case "attachment":
416
+ return {
417
+ refusal: `${label} holds a file, which cannot arrive as typed-in text. Open it and attach the file.`
418
+ };
419
+ case "relation":
420
+ return {
421
+ refusal: `${label} points at another record, so it is chosen rather than typed. Open it and pick the record.`
422
+ };
423
+ case "formula":
424
+ case "lookup":
425
+ case "rollup":
426
+ return {
427
+ refusal: `${label} is worked out by the system, so nothing can be written into it.`
428
+ };
429
+ default:
430
+ return { value: raw };
431
+ }
432
+ }
433
+ function coerceTypedAnswers(fields, answers, opts = {}) {
434
+ const byKey = new Map(fields.map((f) => [f.key, f]));
435
+ const values = {};
436
+ const refusals = {};
437
+ const unknownKeys = [];
438
+ for (const [key, given] of Object.entries(answers)) {
439
+ const field = byKey.get(key);
440
+ if (!field) {
441
+ unknownKeys.push(key);
442
+ values[key] = given;
443
+ continue;
444
+ }
445
+ if (typeof given !== "string") {
446
+ values[key] = given;
447
+ continue;
448
+ }
449
+ const opt = {};
450
+ const choices = opts.options?.get(key);
451
+ if (choices) opt.options = choices;
452
+ if (opts.kernel) opt.kernel = opts.kernel;
453
+ const label = opts.labels?.[key];
454
+ if (label) opt.label = label;
455
+ const answered = coerceTypedAnswer(field, given, opt);
456
+ if ("refusal" in answered) refusals[key] = answered.refusal;
457
+ else values[key] = answered.value;
458
+ }
459
+ return { values, refusals, unknownKeys };
460
+ }
461
+
254
462
  // src/pipeline.ts
255
463
  var PIPELINE_RULE_KINDS = ["moves", "requires", "who", "limit", "entry"];
256
464
  var STAGE_RULE_ON_FAIL = ["refuse", "require_approval"];
@@ -1082,6 +1290,8 @@ var STORE_DOORS = [
1082
1290
  { name: "relation_target_ours", args: "p_entity_token text, p_row_id uuid", returns: "jsonb", security: "definer" },
1083
1291
  { name: "relation_targets", args: "p_organization_id uuid, p_record_id uuid, p_via_key text", returns: "SETOF uuid", security: "definer" },
1084
1292
  { name: "relation_uncarry", args: "p_organization_id uuid, p_container_id uuid, p_item_id uuid", returns: "integer", security: "definer" },
1293
+ { name: "relation_words", args: "p_organization_id uuid, p_field_id uuid, p_value text", returns: "text", security: "definer" },
1294
+ { name: "relation_words_many", args: "p_organization_id uuid, p_field_id uuid, p_record_ids uuid[]", returns: "TABLE(record_id uuid, words text)", security: "definer" },
1085
1295
  { name: "reopen_declared_doors", args: "", returns: "TABLE(reopened text)", security: "definer" },
1086
1296
  { name: "required_epoch", args: "p_container_type text, p_container_id uuid, p_item_type text, p_item_id uuid", returns: "bigint", security: "definer" },
1087
1297
  { name: "resolve_first_match", args: "p_organization_id uuid, p_record_id uuid", returns: "jsonb", security: "invoker" },
@@ -1278,9 +1488,9 @@ var STORE_REFUSAL_CODES = [
1278
1488
  { code: "22007", raised_by: "dashboard_window_sql" },
1279
1489
  { code: "22012", raised_by: "rule_eval" },
1280
1490
  { code: "22023", raised_by: "_booking_availability,_entity_custom_fields_guard,_take_op_id,_value_envelope,_work_shape_guard,actor_word,agg_assert_key,agg_sql,anon_token_issue,booking_declare,containment_parent,dashboard_block_normalize,dashboard_window_sql,doc_render_body,doc_render_write,doc_signature_write,enrich_declare,enrich_land,enrich_normalize,enrich_pin,entity_value_write,external_history_event,external_source_declare,external_stub_upsert,external_write_through,field_history,io_export,io_import_begin,io_import_finish,io_import_plan,io_import_report,io_proposal_accept,migrate_merge,migrate_reclass,my_levels,organization_clear,page_size,pin_agent_cells,pipeline_move,portal_declare,provenance_write,query_by_coordinates,query_rollup,record_update,record_write_many,relation_carry,rule_eval,rule_members,share_grant,share_lane_set,sign_request_create,sign_request_sign,subscription_declare,table_rules,visibility_as_of,work_approval_request,work_list,work_slot_hold" },
1281
- { code: "23503", raised_by: "_booking_availability,_checklist_instantiate,_containment_guard,_field_document_for,_pipeline_stage_key,_record_rule_uses,agg_digest_assemble,anon_publish,anon_rate_take,anon_token_issue,anon_write,assert_organization_wall,booking_cancel,booking_confirm,booking_declare,booking_hold,booking_reschedule,capture_publish,checklist_declare,checklist_steps_table,comment_write,dashboard_declare,delete_rule,doc_sign,doc_template_save,enrich_declare,enrich_due,enrich_land,form_declare,form_submit,home_add,inbound_declare,inbound_mail_land,inbound_set,io_comment_write,io_import_begin,io_import_finish,io_import_report,io_import_rows,io_proposal_accept,migrate_demote,migrate_promote,pipeline_board,pipeline_declare,pipeline_move,pipeline_read,pipeline_transition_refusal,promote_field,promote_table,record_applicability,record_reparent,relation_carry,relation_own,resolve_first_match,rule_applies,rule_declare,rule_eval,rule_members,rule_run,sign_request_create,subscription_mute,subscription_preview,view_declare,work_set_state,work_take_assignment,work_template_instantiate" },
1491
+ { code: "23503", raised_by: "_booking_availability,_checklist_instantiate,_containment_guard,_display_spec_for,_field_document_for,_pipeline_stage_key,_record_rule_uses,agg_digest_assemble,anon_publish,anon_rate_take,anon_token_issue,anon_write,assert_organization_wall,booking_cancel,booking_confirm,booking_declare,booking_hold,booking_reschedule,capture_publish,checklist_declare,checklist_steps_table,comment_write,dashboard_declare,delete_rule,doc_sign,doc_template_save,enrich_declare,enrich_due,enrich_land,form_declare,form_submit,home_add,inbound_declare,inbound_mail_land,inbound_set,io_comment_write,io_import_begin,io_import_finish,io_import_report,io_import_rows,io_proposal_accept,migrate_demote,migrate_promote,pipeline_board,pipeline_declare,pipeline_move,pipeline_read,pipeline_transition_refusal,promote_field,promote_table,record_applicability,record_reparent,relation_carry,relation_own,resolve_first_match,rule_applies,rule_declare,rule_eval,rule_members,rule_run,sign_request_create,subscription_mute,subscription_preview,view_declare,work_set_state,work_take_assignment,work_template_instantiate" },
1282
1492
  { code: "23505", raised_by: "_unique_rule_holds,doc_sign,doc_signature_write,entity_field_declare,field_declare,home_add,inbound_declare,io_proposal_accept,relation_carry,relation_own,share_grant,sign_request_cancel,sign_request_create,table_share_outside_invite,table_share_outside_resend,work_approval_decide,work_slot_hold" },
1283
- { code: "23514", raised_by: "_checklist_instantiate,_checklist_step_guard,_containment_guard,_dated_values_guard,_derived_fields,_entity_custom_fields_guard,_field_class_guard,_field_document_for,_field_shape_guard,_field_type_parity_guard,_merge_field_shape_guard,_merge_field_temporal_guard,_promoted_field_cap_guard,_record_rule_uses,_resolve_choice_words,_rule_shape_guard,_rule_topology_guard,_store_relation_edge_names_its_field,_table_columns_word_guard,_table_shape_guard,_undeclared_key_guard,_value_envelope,_work_shape_guard,_workdoors_approval_guard,assert_columns_are_defined,assert_entity_is_organization_scoped,booking_declare,capture_publish,capture_sheet_declare,checklist_declare,checklist_step_complete,doc_sign,doc_signature_write,doc_template_save,entity_field_declare,entity_field_retire,entity_field_update,entity_records_find,entity_table,field_declare,field_retire,field_update,home_add,io_import_rows,migrate_merge,relation_carry,relation_own,sign_request_create,sign_request_sign,table_declare,validate_value_envelope,validate_values,work_set_state,work_slots_declare,work_take_assignment,work_template_instantiate" },
1493
+ { code: "23514", raised_by: "_checklist_instantiate,_checklist_step_guard,_containment_guard,_dated_values_guard,_derived_fields,_display_spec_for,_entity_custom_fields_guard,_field_class_guard,_field_document_for,_field_shape_guard,_field_type_parity_guard,_merge_field_shape_guard,_merge_field_temporal_guard,_promoted_field_cap_guard,_record_rule_uses,_resolve_choice_words,_rule_shape_guard,_rule_topology_guard,_store_relation_edge_names_its_field,_table_columns_word_guard,_table_shape_guard,_undeclared_key_guard,_value_envelope,_with_display,_work_shape_guard,_workdoors_approval_guard,assert_columns_are_defined,assert_entity_is_organization_scoped,booking_declare,capture_publish,capture_sheet_declare,checklist_declare,checklist_step_complete,doc_sign,doc_signature_write,doc_template_save,entity_field_declare,entity_field_retire,entity_field_update,entity_records_find,entity_table,field_declare,field_retire,field_update,home_add,io_import_rows,migrate_merge,relation_carry,relation_own,sign_request_create,sign_request_sign,table_declare,validate_value_envelope,validate_values,work_set_state,work_slots_declare,work_take_assignment,work_template_instantiate" },
1284
1494
  { code: "40001", raised_by: "has_visibility_at" },
1285
1495
  { code: "42501", raised_by: "_doc_render_immutable,_doc_signature_immutable,_field_definition_write,_field_write_door,_rule_definition_write,_store_door,anon_capture,anon_inbound_land,anon_publish,anon_token_issue,anon_token_verify,anon_write,assert_client_may_change,assert_client_may_open,assert_client_may_reach,assert_may_know_table,assert_organization_admin,assert_store_door,booking_confirm,booking_declare,booking_hold,capture_submit,checklist_run,comment_write,conversation_scope_bind,conversation_scope_unbind,enrich_declare,enrich_due,enrich_land,enrich_normalize,entity_field_declare,entity_field_retire,entity_field_update,entity_value_write,external_write_through,form_declare,form_submit,inbound_mail_land,io_comment_resolve,io_comment_write,io_import_begin,io_import_finish,io_restore,migrate_purge,migrate_reclass,my_levels,organization_clear,portal_declare,portal_invitation,portal_invite,portal_principal_bind,promote_table,query_visibility_parity,read_record,read_records,read_records_by_ids,record_table,share_grant,subscription_mute,subscription_preview,table_share_outside_accept,table_share_outside_invite,table_share_outside_resend,visibility_as_of,work_approval_decide,work_approval_read,work_approval_request,work_person" },
1286
1496
  { code: "42703", raised_by: "entity_table,portal_declare" },
@@ -2436,6 +2646,18 @@ var DOORS = {
2436
2646
  promotedRead: "promoted_read",
2437
2647
  relationTargets: "relation_targets",
2438
2648
  relationOwn: "relation_own",
2649
+ // REL-DISP, 2026-09-21. WHAT A RELATION CELL READS, through that column's own
2650
+ // display spec. `custom.record_words` is declared server_only — it takes a bare
2651
+ // record id with no field to scope it — so these are how a screen gets the
2652
+ // words, and they are the ONE way in: three separate copies of "read tableList,
2653
+ // find title_field, call rowName" lived in labels.tsx, RelationPicker.tsx and
2654
+ // PortalsPanel.tsx, and none of them could ever have shown two columns joined.
2655
+ // The `_many` door is the batch TAILS-2 recorded as missing ("there is still no
2656
+ // batch door on the store"), which is why the browser resolved labels one id at
2657
+ // a time. The ladder is NOT batched: the store asks it per record inside
2658
+ // `custom._words_for`, so batching buys a round trip and never a disclosure.
2659
+ relationWords: "relation_words",
2660
+ relationWordsMany: "relation_words_many",
2439
2661
  homeAdd: "home_add",
2440
2662
  recordReparent: "record_reparent",
2441
2663
  containmentChain: "containment_chain",
@@ -3315,6 +3537,26 @@ function createRecordsClient(config) {
3315
3537
  "fieldRetire"
3316
3538
  );
3317
3539
  },
3540
+ async relationWords({ field_id, value }) {
3541
+ return callDoor(
3542
+ DOORS.relationWords,
3543
+ { p_organization_id: org, p_field_id: field_id, p_value: value },
3544
+ "relationWords"
3545
+ );
3546
+ },
3547
+ async relationWordsMany({ field_id, record_ids }) {
3548
+ const result = await callDoor(
3549
+ DOORS.relationWordsMany,
3550
+ { p_organization_id: org, p_field_id: field_id, p_record_ids: record_ids },
3551
+ "relationWordsMany"
3552
+ );
3553
+ if (!result.ok) return result;
3554
+ const map = {};
3555
+ for (const row of result.data ?? []) {
3556
+ if (row?.record_id && typeof row.words === "string") map[row.record_id] = row.words;
3557
+ }
3558
+ return { ok: true, data: map };
3559
+ },
3318
3560
  async tableDeclare({ spec, homeId }) {
3319
3561
  return callDoor(
3320
3562
  DOORS.tableDeclare,