@ai-matrx/records 0.57.0 → 0.57.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog — @ai-matrx/records
2
2
 
3
+ ## 0.57.1 (unpublished — lane RECORDS-UI-FIX, guide re-walk 2026-09-23)
4
+
5
+ * **A refusal is not an app error.** The default error sink printed every refusal through
6
+ `console.error`, and matrx-frontend's error capture counts every `console.error`, so one
7
+ refused value lit a red "2 errors" pill. The codes a person causes (`refused_by_rule`,
8
+ `stale_write`, `invalid_argument`, `already_exists`, `missing_reference`) now go to
9
+ `console.info`; everything else stays `console.error`. New exports (core):
10
+ `isAPersonsRefusal`, `A_PERSONS_REFUSAL`. Test: `src/__tests__/a-refusal-is-never-an-app-error.test.ts`.
11
+ * **Every screen hears the writes made on its page.** `recordUpdate`, `recordDelete`,
12
+ `recordRestore`, `restoreVersion` and `valueRestore` announce the record they wrote on
13
+ success. New exports: core `announceRecordChanged` / `onRecordChanged`, react
14
+ `useRecordChangeRevision(recordId)`; `useRecord` re-reads on it.
15
+
16
+ ## Unreleased — STORE-RULE-GAPS (2026-09-23)
17
+
18
+ * `FieldRule` gains `example` (a pattern's "how to write it") and `min` (a length's shortest);
19
+ `value` is optional for a min-only length rule. `NewFieldDeclaration.allow_other` (sent as
20
+ `config.allow_other`) and `FieldPatch.allow_other`; `allowsOtherValues(field)`.
21
+ `PredictedRefusal.detail`.
22
+ * The mirror predicts the store's new sentences word for word ("… has to be at least 20
23
+ characters long"; a pattern with an example hints "Enter it like …" with `detail.example`),
24
+ and no longer predicts a refusal for a list value that is not a uuid — the store holds option
25
+ KEYS (CHOICE-VALUE) and a list that takes other values adds an unknown word.
26
+ * Needs matrx-frontend `storerulegaps_an_example_a_shortest_length_and_other_choices.sql`.
27
+
3
28
  ## 0.57.0
4
29
 
5
30
  **The grid primitives are live, so their doors are in the catalogue.** (published by lane
@@ -2199,6 +2199,7 @@ __export(core_exports, {
2199
2199
  ARCHIVE_LANES: () => ARCHIVE_LANES,
2200
2200
  ARCHIVE_LANE_LABEL: () => ARCHIVE_LANE_LABEL,
2201
2201
  ARCHIVE_LANE_MEANS: () => ARCHIVE_LANE_MEANS,
2202
+ A_PERSONS_REFUSAL: () => A_PERSONS_REFUSAL,
2202
2203
  COMPUTE_ON: () => COMPUTE_ON,
2203
2204
  CONTEXT_POLICIES: () => CONTEXT_POLICIES,
2204
2205
  DECLARABLE_FIELD_KINDS: () => DECLARABLE_FIELD_KINDS,
@@ -2245,6 +2246,8 @@ __export(core_exports, {
2245
2246
  VALUE_ALTERNATE_KEYS: () => VALUE_ALTERNATE_KEYS,
2246
2247
  VALUE_ENVELOPE_KEYS: () => VALUE_ENVELOPE_KEYS,
2247
2248
  actionRefusals: () => actionRefusals,
2249
+ allowsOtherValues: () => allowsOtherValues,
2250
+ announceRecordChanged: () => announceRecordChanged,
2248
2251
  archivedByLine: () => archivedByLine,
2249
2252
  archivedWhen: () => archivedWhen,
2250
2253
  asPermissionLevel: () => asPermissionLevel,
@@ -2277,6 +2280,7 @@ __export(core_exports, {
2277
2280
  importXlsx: () => importXlsx,
2278
2281
  indexedDbStore: () => indexedDbStore,
2279
2282
  intersectIds: () => intersectIds,
2283
+ isAPersonsRefusal: () => isAPersonsRefusal,
2280
2284
  isArchiveLane: () => isArchiveLane,
2281
2285
  isDecided: () => isDecided,
2282
2286
  isOwnOp: () => isOwnOp,
@@ -2291,6 +2295,7 @@ __export(core_exports, {
2291
2295
  mintClientKey: () => mintClientKey,
2292
2296
  mintOpId: () => mintOpId,
2293
2297
  ok: () => ok,
2298
+ onRecordChanged: () => onRecordChanged,
2294
2299
  openCaptureQueue: () => openCaptureQueue,
2295
2300
  optionKey: () => optionKey,
2296
2301
  optionLabel: () => optionLabel,
@@ -2439,6 +2444,11 @@ function highestLevel(left, right) {
2439
2444
  return ORDINAL[left] >= ORDINAL[right] ? left : right;
2440
2445
  }
2441
2446
 
2447
+ // src/field.ts
2448
+ function allowsOtherValues(field) {
2449
+ return field.type === "list" && field.config?.["allow_other"] === true;
2450
+ }
2451
+
2442
2452
  // src/choice.ts
2443
2453
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2444
2454
  function optionKey(option) {
@@ -5697,6 +5707,22 @@ function staleWriteDetail(error) {
5697
5707
  };
5698
5708
  }
5699
5709
 
5710
+ // src/core/recordChanges.ts
5711
+ var listeners = /* @__PURE__ */ new Set();
5712
+ function announceRecordChanged(recordId) {
5713
+ for (const listener of [...listeners]) {
5714
+ try {
5715
+ listener(recordId);
5716
+ } catch (thrown) {
5717
+ console.error("[@ai-matrx/records] a record-change listener threw", thrown);
5718
+ }
5719
+ }
5720
+ }
5721
+ function onRecordChanged(listener) {
5722
+ listeners.add(listener);
5723
+ return () => listeners.delete(listener);
5724
+ }
5725
+
5700
5726
  // src/core/query.ts
5701
5727
  var DEFAULT_PAGE_SIZE = 50;
5702
5728
  function planQuery(query) {
@@ -5738,12 +5764,22 @@ function intersectIds(sets) {
5738
5764
 
5739
5765
  // src/core/client.ts
5740
5766
  var DEFAULT_SCHEMA = "custom";
5767
+ var A_PERSONS_REFUSAL = /* @__PURE__ */ new Set([
5768
+ "refused_by_rule",
5769
+ "stale_write",
5770
+ "invalid_argument",
5771
+ "already_exists",
5772
+ "missing_reference"
5773
+ ]);
5774
+ function isAPersonsRefusal(code) {
5775
+ return A_PERSONS_REFUSAL.has(code);
5776
+ }
5741
5777
  function sink(config) {
5742
- return config.onError ?? ((e) => (
5743
- // The defined degradation, stated: loud, with the store's own words, and
5744
- // naming where it happened. Never silence.
5745
- console.error(`[@ai-matrx/records] ${e.where}: ${e.code} \u2014 ${e.message}${e.hint ? ` (${e.hint})` : ""}`)
5746
- ));
5778
+ return config.onError ?? ((e) => {
5779
+ const line = `[@ai-matrx/records] ${e.where}: ${e.code} \u2014 ${e.message}${e.hint ? ` (${e.hint})` : ""}`;
5780
+ if (isAPersonsRefusal(e.code)) console.info(line);
5781
+ else console.error(line);
5782
+ });
5747
5783
  }
5748
5784
  function createRecordsClient(config) {
5749
5785
  const schema = config.schema ?? DEFAULT_SCHEMA;
@@ -5850,21 +5886,26 @@ function createRecordsClient(config) {
5850
5886
  },
5851
5887
  "recordUpdate"
5852
5888
  );
5889
+ if (result.ok) announceRecordChanged(record_id);
5853
5890
  return result;
5854
5891
  },
5855
5892
  async recordDelete({ record_id }) {
5856
- return callDoor(
5893
+ const result = await callDoor(
5857
5894
  DOORS.recordDelete,
5858
5895
  { p_organization_id: org, p_record_id: record_id },
5859
5896
  "recordDelete"
5860
5897
  );
5898
+ if (result.ok) announceRecordChanged(record_id);
5899
+ return result;
5861
5900
  },
5862
5901
  async recordRestore({ record_id }) {
5863
- return callDoor(
5902
+ const result = await callDoor(
5864
5903
  DOORS.recordRestore,
5865
5904
  { p_organization_id: org, p_record_id: record_id },
5866
5905
  "recordRestore"
5867
5906
  );
5907
+ if (result.ok) announceRecordChanged(record_id);
5908
+ return result;
5868
5909
  },
5869
5910
  async tableArchive({ table_id, chunk, includeTable }) {
5870
5911
  return callDoor(
@@ -5914,6 +5955,24 @@ function createRecordsClient(config) {
5914
5955
  })
5915
5956
  );
5916
5957
  },
5958
+ async tableArchived({ table_id }) {
5959
+ const kernel = await callDoor(DOORS.tableKernelId, {}, "tableArchived.kernel");
5960
+ if (!kernel.ok) return err(kernel.error);
5961
+ const PAGE = 200;
5962
+ for (let page = 0; page < 25; page += 1) {
5963
+ const answered = await this.listArchived({
5964
+ table_id: kernel.data,
5965
+ lane: "org",
5966
+ limit: PAGE,
5967
+ offset: page * PAGE
5968
+ });
5969
+ if (!answered.ok) return err(answered.error);
5970
+ const hit = answered.data.rows.find((row) => row.id === table_id);
5971
+ if (hit) return ok(hit);
5972
+ if (answered.data.total !== null) return ok(null);
5973
+ }
5974
+ return ok(null);
5975
+ },
5917
5976
  async listArchived({ table_id, lane = DEFAULT_ARCHIVE_LANE, limit = 50, offset = 0 }) {
5918
5977
  const door = await callDoor(
5919
5978
  DOORS.readRecordsArchived,
@@ -6115,9 +6174,11 @@ function createRecordsClient(config) {
6115
6174
  );
6116
6175
  },
6117
6176
  async fieldDeclare({ table_id, spec }) {
6177
+ const { allow_other, ...rest } = spec;
6178
+ const p_spec = allow_other === void 0 ? rest : { ...rest, config: { ...rest.config ?? {}, allow_other } };
6118
6179
  return callDoor(
6119
6180
  DOORS.fieldDeclare,
6120
- { p_organization_id: org, p_table_id: table_id, p_spec: spec },
6181
+ { p_organization_id: org, p_table_id: table_id, p_spec },
6121
6182
  "fieldDeclare"
6122
6183
  );
6123
6184
  },
@@ -7085,14 +7146,16 @@ function createRecordsClient(config) {
7085
7146
  );
7086
7147
  },
7087
7148
  async restoreVersion({ record_id, version }) {
7088
- return callDoor(
7149
+ const result = await callDoor(
7089
7150
  DOORS.restoreVersion,
7090
7151
  { p_organization_id: org, p_record_id: record_id, p_version: version },
7091
7152
  "restoreVersion"
7092
7153
  );
7154
+ if (result.ok) announceRecordChanged(record_id);
7155
+ return result;
7093
7156
  },
7094
7157
  async valueRestore({ record_id, field_key, version }) {
7095
- return callDoor(
7158
+ const answered = await callDoor(
7096
7159
  DOORS.valueRestore,
7097
7160
  {
7098
7161
  p_organization_id: org,
@@ -7102,6 +7165,8 @@ function createRecordsClient(config) {
7102
7165
  },
7103
7166
  "valueRestore"
7104
7167
  );
7168
+ if (answered.ok) announceRecordChanged(record_id);
7169
+ return answered;
7105
7170
  },
7106
7171
  async commentThread({ record_id, include_resolved }) {
7107
7172
  return callDoor(
@@ -7883,12 +7948,30 @@ function ruleRefusal(field, rule, item, values) {
7883
7948
  }
7884
7949
  return null;
7885
7950
  case "length":
7886
- if (typeof item === "string" && item.length > Number(rule.value)) {
7951
+ if (typeof item !== "string") return null;
7952
+ if (rule.value !== void 0 && rule.value !== null && rule.value !== "" && item.length > Number(rule.value)) {
7887
7953
  return { ...base, message: `${label} is longer than ${rule.value} characters`, hint: "FLD-3." };
7888
7954
  }
7955
+ if (typeof rule.min === "number" && item !== "" && item.length < rule.min) {
7956
+ return {
7957
+ ...base,
7958
+ message: `${label} has to be at least ${rule.min} characters long`,
7959
+ hint: "FLD-3.",
7960
+ detail: { field_key: field.key, rule: "length", min: rule.min }
7961
+ };
7962
+ }
7889
7963
  return null;
7890
7964
  case "pattern":
7891
7965
  if (typeof item === "string" && !new RegExp(String(rule.value)).test(item)) {
7966
+ const example = typeof rule.example === "string" ? rule.example.trim() : "";
7967
+ if (example !== "") {
7968
+ return {
7969
+ ...base,
7970
+ message: `${label} is not written the way this field expects`,
7971
+ hint: `Enter it like ${example}.`,
7972
+ detail: { field_key: field.key, rule: "pattern", example }
7973
+ };
7974
+ }
7892
7975
  return { ...base, message: `${label} is not written the way this field expects`, hint: "FLD-3." };
7893
7976
  }
7894
7977
  return null;
@@ -7973,12 +8056,6 @@ function predictValueRefusals(fields, values, recordType) {
7973
8056
  message: `${label} takes one of its choices, and it was given a ${jsonType(item)}`,
7974
8057
  hint: "FLD-5 / FLD-6: a list field stores the id of the option RECORD, because every pick-list is already a Table."
7975
8058
  });
7976
- } else if (!UUID2.test(item)) {
7977
- found.push({
7978
- ...base,
7979
- message: `${label} was given a choice that is not one of its choices`,
7980
- hint: `REC-51: option membership. ${label} stores the id of an option record, and what it was given is not an id at all.`
7981
- });
7982
8059
  }
7983
8060
  } else if (field.type === "relation") {
7984
8061
  if (typeof item !== "string") {