nucleus-core-ts 0.9.935 → 0.9.936

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/dist/.build-ok CHANGED
@@ -1 +1 @@
1
- 0.9.935
1
+ 0.9.936
@@ -182,6 +182,7 @@ export function MappingEditor({ mapping, targets, targetsLoading, endpoints, fre
182
182
  missingRecordColumn: draft.missingRecordColumn ?? '',
183
183
  missingRecordPolicy: draft.missingRecordPolicy,
184
184
  missingRecordValue: draft.missingRecordValue ?? '',
185
+ removalScopeColumn: draft.removalScopeColumn ?? '',
185
186
  originColumn: draft.originColumn ?? '',
186
187
  originValue: draft.originValue ?? '',
187
188
  maxRemovalShare: draft.maxRemovalShare ?? 0,
@@ -194,6 +195,9 @@ export function MappingEditor({ mapping, targets, targetsLoading, endpoints, fre
194
195
  onMissingRecordValueChange: (value)=>patch({
195
196
  missingRecordValue: value
196
197
  }),
198
+ onRemovalScopeColumnChange: (value)=>patch({
199
+ removalScopeColumn: value
200
+ }),
197
201
  onOriginColumnChange: (value)=>patch({
198
202
  originColumn: value
199
203
  }),
@@ -5,6 +5,7 @@ import { cn } from '../../../utils/cn';
5
5
  import { useIntegrationsStore } from '../store';
6
6
  import { useLabels } from '../text/context';
7
7
  import { integrationsPageTheme } from '../theme';
8
+ import { mappingStaleness } from './mappingStaleness';
8
9
  import { Badge, Field, Notice } from './Primitives';
9
10
  import { formatMoment } from './runFormat';
10
11
  import { ListSkeleton } from './Skeletons';
@@ -39,6 +40,19 @@ const WRITE_MODE_TONE = {
39
40
  export function MappingList({ actions, sourceId, selectedId, reloadToken, onSelect, onRun }) {
40
41
  const t = useLabels().errors;
41
42
  const labels = useLabels();
43
+ /*
44
+ * Read AFTER mount, never during render.
45
+ *
46
+ * "How long ago" is a function of the clock, and the clock on the server that
47
+ * renders this HTML is not the one in the browser that hydrates it. Computing
48
+ * it in render is what put two different strings in the same row and had React
49
+ * swap one for the other on mount — the same fault the last-run timestamp was
50
+ * already fixed for. Null until mounted means the first paint has no age
51
+ * badges at all, which both renders agree on.
52
+ */ const [now, setNow] = useState(null);
53
+ useEffect(()=>{
54
+ setNow(Date.now());
55
+ }, []);
42
56
  const store = useIntegrationsStore();
43
57
  const uid = useId();
44
58
  const search = store.mappingsSearch;
@@ -201,7 +215,27 @@ export function MappingList({ actions, sourceId, selectedId, reloadToken, onSele
201
215
  /*#__PURE__*/ _jsx("span", {
202
216
  className: theme.list.itemMeta,
203
217
  children: describeLastRun(mapping.lastRunAt, labels.ui)
204
- })
218
+ }),
219
+ (()=>{
220
+ if (now === null) return null;
221
+ const health = mappingStaleness({
222
+ lastRunAt: mapping.lastRunAt,
223
+ scheduleEnabled: mapping.scheduleEnabled,
224
+ now
225
+ });
226
+ return /*#__PURE__*/ _jsxs(_Fragment, {
227
+ children: [
228
+ health.unscheduled ? /*#__PURE__*/ _jsx(Badge, {
229
+ tone: "warning",
230
+ children: labels.ui.unscheduled
231
+ }) : null,
232
+ health.tone === 'warning' && health.ageDays !== null ? /*#__PURE__*/ _jsx(Badge, {
233
+ tone: "warning",
234
+ children: labels.ui.behindByDays(health.ageDays)
235
+ }) : null
236
+ ]
237
+ });
238
+ })()
205
239
  ]
206
240
  })
207
241
  ]
@@ -8,6 +8,13 @@ export type MappingWriteModeFieldsProps = {
8
8
  missingRecordPolicy: MissingRecordPolicyValue;
9
9
  missingRecordColumn: string;
10
10
  missingRecordValue: string;
11
+ /**
12
+ * Target column carrying whichever value this mapping's calls vary.
13
+ *
14
+ * Set, removal is confined to the values THIS run read, so one call answering
15
+ * with an error costs that slice its cleanup rather than refusing the run.
16
+ */
17
+ removalScopeColumn: string;
11
18
  /** The mark this mapping puts on its rows — and the only ones it may remove. */
12
19
  originColumn: string;
13
20
  originValue: string;
@@ -20,11 +27,12 @@ export type MappingWriteModeFieldsProps = {
20
27
  onMissingRecordPolicyChange: (policy: MissingRecordPolicyValue) => void;
21
28
  onMissingRecordColumnChange: (value: string) => void;
22
29
  onMissingRecordValueChange: (value: string) => void;
30
+ onRemovalScopeColumnChange: (value: string) => void;
23
31
  onOriginColumnChange: (value: string) => void;
24
32
  onOriginValueChange: (value: string) => void;
25
33
  onMaxRemovalShareChange: (value: number) => void;
26
34
  };
27
- export declare function MappingWriteModeFields({ idPrefix, writeMode, missingRecordPolicy, missingRecordColumn, missingRecordValue, originColumn, originValue, maxRemovalShare, dedupConfigured, disabled, onWriteModeChange, onMissingRecordPolicyChange, onMissingRecordColumnChange, onMissingRecordValueChange, onOriginColumnChange, onOriginValueChange, onMaxRemovalShareChange, }: MappingWriteModeFieldsProps): import("react/jsx-runtime").JSX.Element;
35
+ export declare function MappingWriteModeFields({ idPrefix, writeMode, missingRecordPolicy, missingRecordColumn, missingRecordValue, originColumn, originValue, maxRemovalShare, dedupConfigured, disabled, onWriteModeChange, onMissingRecordPolicyChange, onMissingRecordColumnChange, onMissingRecordValueChange, removalScopeColumn, onRemovalScopeColumnChange, onOriginColumnChange, onOriginValueChange, onMaxRemovalShareChange, }: MappingWriteModeFieldsProps): import("react/jsx-runtime").JSX.Element;
28
36
  /**
29
37
  * Stored as a share, typed as a percentage.
30
38
  *
@@ -34,13 +34,14 @@ function toMissingPolicy(value) {
34
34
  /** Deactivate has nowhere to write its flag without both of these. */ export function missingRecordFieldsIncomplete(policy, column, value) {
35
35
  return policy === 'deactivate' && (column.trim() === '' || value.trim() === '');
36
36
  }
37
- export function MappingWriteModeFields({ idPrefix, writeMode, missingRecordPolicy, missingRecordColumn, missingRecordValue, originColumn, originValue, maxRemovalShare, dedupConfigured, disabled, onWriteModeChange, onMissingRecordPolicyChange, onMissingRecordColumnChange, onMissingRecordValueChange, onOriginColumnChange, onOriginValueChange, onMaxRemovalShareChange }) {
37
+ export function MappingWriteModeFields({ idPrefix, writeMode, missingRecordPolicy, missingRecordColumn, missingRecordValue, originColumn, originValue, maxRemovalShare, dedupConfigured, disabled, onWriteModeChange, onMissingRecordPolicyChange, onMissingRecordColumnChange, onMissingRecordValueChange, removalScopeColumn, onRemovalScopeColumnChange, onOriginColumnChange, onOriginValueChange, onMaxRemovalShareChange }) {
38
38
  const labels = useLabels();
39
39
  const modeId = `${idPrefix}-write-mode`;
40
40
  const policyId = `${idPrefix}-missing-policy`;
41
41
  const columnId = `${idPrefix}-missing-column`;
42
42
  const valueId = `${idPrefix}-missing-value`;
43
43
  const originColumnId = `${idPrefix}-origin-column`;
44
+ const removalScopeColumnId = `${idPrefix}-removal-scope-column`;
44
45
  const originValueId = `${idPrefix}-origin-value`;
45
46
  const shareId = `${idPrefix}-max-removal-share`;
46
47
  const columnMissing = missingRecordPolicy === 'deactivate' && missingRecordColumn.trim() === '';
@@ -171,6 +172,20 @@ export function MappingWriteModeFields({ idPrefix, writeMode, missingRecordPolic
171
172
  })
172
173
  ]
173
174
  }),
175
+ /*#__PURE__*/ _jsx(Field, {
176
+ hint: labels.mappings.removalScopeColumnHint,
177
+ htmlFor: removalScopeColumnId,
178
+ label: labels.mappings.removalScopeColumn,
179
+ children: /*#__PURE__*/ _jsx("input", {
180
+ "aria-describedby": `${removalScopeColumnId}-hint`,
181
+ className: theme.field.input,
182
+ disabled: disabled,
183
+ id: removalScopeColumnId,
184
+ onChange: (event)=>onRemovalScopeColumnChange(event.target.value),
185
+ placeholder: "company_code",
186
+ value: removalScopeColumn
187
+ })
188
+ }),
174
189
  originColumn.trim() === '' ? /*#__PURE__*/ _jsx(Notice, {
175
190
  title: labels.mappings.originUnsetTitle,
176
191
  tone: "warning",
@@ -26,6 +26,7 @@ export type MappingDraft = {
26
26
  missingRecordColumn?: string | null;
27
27
  missingRecordValue?: string | null;
28
28
  /** The mark this mapping puts on its rows, and the only ones it may remove. */
29
+ removalScopeColumn?: string | null;
29
30
  originColumn?: string | null;
30
31
  originValue?: string | null;
31
32
  /** The largest share of existing rows one run may remove; 0 is no ceiling. */
@@ -29,6 +29,7 @@ import { missingRecordFieldsIncomplete } from './MappingWriteModeFields';
29
29
  missingRecordPolicy: mapping.missingRecordPolicy,
30
30
  missingRecordColumn: mapping.missingRecordColumn,
31
31
  missingRecordValue: mapping.missingRecordValue,
32
+ removalScopeColumn: mapping.removalScopeColumn ?? '',
32
33
  originColumn: mapping.originColumn ?? '',
33
34
  originValue: mapping.originValue ?? '',
34
35
  maxRemovalShare: mapping.maxRemovalShare ?? 0,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Whether a mapping is actually keeping its table up to date.
3
+ *
4
+ * The list already said "Last run: 2026-08-02 12:51". Next to four siblings that
5
+ * all ran the previous night, that line is the difference between a working
6
+ * integration and a table two days out of date — and it takes arithmetic to see
7
+ * it, so nobody did. What happened instead: a chief executive was missing from
8
+ * the directory, a director's title was the one he had held in July, and people
9
+ * who had left were still listed. All four were the same fact, and the screen
10
+ * that knew it showed a timestamp.
11
+ *
12
+ * The cause underneath was smaller still: that mapping had no schedule. Its
13
+ * siblings ran nightly; it only ever ran when somebody pressed a button, and
14
+ * nobody had pressed it since Sunday. Nothing on the screen said so.
15
+ *
16
+ * So this answers two questions the timestamp cannot: does anything run this on
17
+ * its own, and is what it wrote still recent. Both are properties of the row, so
18
+ * they belong on the row.
19
+ */
20
+ export type MappingStaleness = {
21
+ /** Nothing runs this unless a person does. */
22
+ unscheduled: boolean;
23
+ /** It has never run at all. */
24
+ neverRun: boolean;
25
+ /** Whole days since the last run, null when it has never run. */
26
+ ageDays: number | null;
27
+ /** Worth an operator's attention. */
28
+ tone: 'ok' | 'warning';
29
+ };
30
+ export type StalenessQuestion = {
31
+ lastRunAt?: string | null;
32
+ scheduleEnabled?: boolean | null;
33
+ /** Passed in rather than read, so this is a function of its arguments. */
34
+ now: number;
35
+ /**
36
+ * How old is too old.
37
+ *
38
+ * A day, because every schedule in a normal install runs at least nightly, so
39
+ * a mapping older than that has missed one — and a threshold shorter than the
40
+ * cadence would flag every mapping every morning, which is how a warning
41
+ * stops being read.
42
+ */
43
+ staleAfterHours?: number;
44
+ };
45
+ export declare function mappingStaleness(question: StalenessQuestion): MappingStaleness;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Whether a mapping is actually keeping its table up to date.
3
+ *
4
+ * The list already said "Last run: 2026-08-02 12:51". Next to four siblings that
5
+ * all ran the previous night, that line is the difference between a working
6
+ * integration and a table two days out of date — and it takes arithmetic to see
7
+ * it, so nobody did. What happened instead: a chief executive was missing from
8
+ * the directory, a director's title was the one he had held in July, and people
9
+ * who had left were still listed. All four were the same fact, and the screen
10
+ * that knew it showed a timestamp.
11
+ *
12
+ * The cause underneath was smaller still: that mapping had no schedule. Its
13
+ * siblings ran nightly; it only ever ran when somebody pressed a button, and
14
+ * nobody had pressed it since Sunday. Nothing on the screen said so.
15
+ *
16
+ * So this answers two questions the timestamp cannot: does anything run this on
17
+ * its own, and is what it wrote still recent. Both are properties of the row, so
18
+ * they belong on the row.
19
+ */ const HOUR = 60 * 60 * 1000;
20
+ export function mappingStaleness(question) {
21
+ const unscheduled = question.scheduleEnabled !== true;
22
+ const staleAfter = (question.staleAfterHours ?? 24) * HOUR;
23
+ const parsed = question.lastRunAt ? Date.parse(question.lastRunAt) : Number.NaN;
24
+ if (!Number.isFinite(parsed)) {
25
+ /*
26
+ * Never run is only worth a warning when nothing is going to run it.
27
+ * A mapping created five minutes ago with a schedule on it is not a
28
+ * problem — it is a mapping waiting for tonight.
29
+ */ return {
30
+ unscheduled,
31
+ neverRun: true,
32
+ ageDays: null,
33
+ tone: unscheduled ? 'warning' : 'ok'
34
+ };
35
+ }
36
+ // A clock skewed forward would otherwise report a negative age and read as
37
+ // fresh; the floor at zero keeps the answer honest either way.
38
+ const age = Math.max(0, question.now - parsed);
39
+ const ageDays = Math.floor(age / (24 * HOUR));
40
+ return {
41
+ unscheduled,
42
+ neverRun: false,
43
+ ageDays,
44
+ tone: age > staleAfter ? 'warning' : 'ok'
45
+ };
46
+ }
@@ -237,6 +237,8 @@ export type IntegrationsLabels = {
237
237
  deactivateColumnHint: string;
238
238
  deactivateValue: string;
239
239
  deactivateValueHint: string;
240
+ removalScopeColumn: string;
241
+ removalScopeColumnHint: string;
240
242
  originColumn: string;
241
243
  originColumnHint: string;
242
244
  originValue: string;
@@ -595,6 +597,10 @@ export type IntegrationsLabels = {
595
597
  ui: {
596
598
  tryAgain: string;
597
599
  neverRun: string;
600
+ /** Nothing runs this mapping unless somebody presses the button. */
601
+ unscheduled: string;
602
+ /** How far behind a mapping's last run is, in whole days. */
603
+ behindByDays: (days: number) => string;
598
604
  lastRun: (moment: string) => string;
599
605
  shownOfTotal: (shown: string | number, total: string | number) => string;
600
606
  runImport: string;
@@ -217,6 +217,8 @@
217
217
  deactivateColumnHint: 'A column on the target table.',
218
218
  deactivateValue: 'Deactivation value',
219
219
  deactivateValueHint: 'Written into that column.',
220
+ removalScopeColumn: 'Removal scope column',
221
+ removalScopeColumnHint: 'For a source read through several calls — one per company, branch or tenant. Name the target column carrying whichever value the calls vary, and removal is limited to the values this run actually read: a call that answers with an error costs its own slice its cleanup instead of stopping the whole run. Blank means every row is a candidate, and one failing call refuses every removal.',
220
222
  originColumn: 'Ownership column',
221
223
  originColumnHint: 'A column on the target table that this mapping stamps on every row it writes.',
222
224
  originValue: 'Ownership mark',
@@ -522,6 +524,8 @@
522
524
  ui: {
523
525
  tryAgain: 'Try again',
524
526
  neverRun: 'Never run',
527
+ unscheduled: 'not scheduled',
528
+ behindByDays: (days)=>days <= 1 ? '1 day behind' : `${days} days behind`,
525
529
  lastRun: (moment)=>`Last run ${moment}`,
526
530
  shownOfTotal: (shown, total)=>`${shown} of ${total} shown`,
527
531
  runImport: 'Import',
@@ -188,6 +188,13 @@ export type IntegrationMapping = {
188
188
  missingRecordPolicy: MissingRecordPolicyValue;
189
189
  missingRecordColumn?: string | null;
190
190
  missingRecordValue?: string | null;
191
+ /**
192
+ * Target column carrying whichever value this mapping's calls vary — a
193
+ * company code, a branch, a tenant. Set, removal is confined to the values
194
+ * this run actually read, so one call answering with an error costs that
195
+ * slice its cleanup instead of refusing the whole run.
196
+ */
197
+ removalScopeColumn?: string | null;
191
198
  /**
192
199
  * The mark this mapping puts on every row it writes, and the only rows it is
193
200
  * then allowed to take away. Without it a run that removes missing records