@object-ui/data-objectstack 17.1.0 → 17.3.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/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
10
+ import type { DroppedFieldsEvent } from '@objectstack/spec/data';
10
11
  import type {
11
12
  DataSource,
12
13
  BatchTransactionOperation,
@@ -39,7 +40,7 @@ import {
39
40
  MetadataNotFoundError,
40
41
  BulkOperationError,
41
42
  ConnectionError,
42
- ValidationError,
43
+ DataApiValidationError,
43
44
  createErrorFromResponse,
44
45
  } from './errors';
45
46
 
@@ -588,7 +589,7 @@ export function isConcurrentUpdateError(error: unknown): error is ConcurrentUpda
588
589
  *
589
590
  * Two shapes are recognised:
590
591
  * - `409` + `CONCURRENT_UPDATE` → {@link ConcurrentUpdateError};
591
- * - `400` + `VALIDATION_FAILED` → {@link ValidationError}, carrying the
592
+ * - `400` + `VALIDATION_FAILED` → {@link DataApiValidationError}, carrying the
592
593
  * server's per-field entries so a form can mark the offending inputs
593
594
  * instead of showing one undirected toast.
594
595
  */
@@ -621,7 +622,7 @@ export function normaliseClientError(error: unknown): unknown {
621
622
  })
622
623
  .filter((x): x is { field: string; message: string } => x !== null);
623
624
 
624
- return new ValidationError(
625
+ return new DataApiValidationError(
625
626
  typeof e.message === 'string' ? e.message : 'Validation failed',
626
627
  validationErrors[0]?.field,
627
628
  validationErrors,
@@ -721,16 +722,21 @@ export type BatchProgressListener = (event: BatchProgressEvent) => void;
721
722
  /**
722
723
  * One server-reported write-strip: caller-supplied fields the backend LEGALLY
723
724
  * removed from a write before persisting (a non-system caller cannot seed a
724
- * `readonly` field, a `readonlyWhen` predicate locked it, etc.). Mirrors the
725
- * framework `DroppedFieldsEvent` (spec `DroppedFieldsEventSchema`) structurally
726
- * so we don't pin a client type version — `reason` is kept as a widened string
727
- * for forward-compatibility with reasons added server-side.
725
+ * `readonly` field, a `readonlyWhen` predicate locked it, etc.).
726
+ *
727
+ * THE spec type, re-exported (objectui#3160, objectstack#4115 ledger batch 6).
728
+ * Until then this was a hand copy whose comment said it "mirrors the framework
729
+ * `DroppedFieldsEvent` (spec `DroppedFieldsEventSchema`) structurally so we
730
+ * don't pin a client type version", with `reason` widened from the spec's
731
+ * `'readonly' | 'readonly_when'` to bare `string` "for forward-compatibility
732
+ * with reasons added server-side". Both halves of that reasoning are the
733
+ * failure mode this ledger exists to remove: the spec IS the client type
734
+ * version, and a consumer-side widening of a producer's enum is precisely the
735
+ * lenient fallback AGENTS.md #12 bans — it deletes the only compile-time signal
736
+ * that would tell `AdapterProvider`'s toast wording (which branches on
737
+ * `readonly_when`) that a new reason had appeared.
728
738
  */
729
- export interface DroppedFieldsEvent {
730
- object: string;
731
- fields: string[];
732
- reason: string;
733
- }
739
+ export type { DroppedFieldsEvent };
734
740
 
735
741
  /**
736
742
  * Emitted after a create/update whose response carried `droppedFields`
@@ -765,9 +771,68 @@ function stableStringify(value: unknown): string {
765
771
  return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
766
772
  }
767
773
 
774
+ /**
775
+ * Whether two values are the SAME as far as the wire is concerned — used to
776
+ * tell a write-strip that lost something from one that lost nothing
777
+ * (objectui#3484).
778
+ *
779
+ * Deliberately strict: `1` and `'1'` are NOT the same here. A false "these are
780
+ * equal" silently swallows a warning about a value the user really did lose,
781
+ * which is the worse of the two errors; a false "these differ" only warns
782
+ * about a no-op. `null` and `undefined` both mean "empty" and do match.
783
+ */
784
+ function sameWireValue(a: unknown, b: unknown): boolean {
785
+ if (a === b) return true;
786
+ const aEmpty = a === null || a === undefined;
787
+ const bEmpty = b === null || b === undefined;
788
+ if (aEmpty || bEmpty) return aEmpty && bEmpty;
789
+ return stableStringify(a) === stableStringify(b);
790
+ }
791
+
792
+ /**
793
+ * Drop the fields whose strip changed nothing — the caller supplied exactly
794
+ * the value the record already holds (objectui#3484).
795
+ *
796
+ * The server is not wrong to report those: the client DID send the key and the
797
+ * engine DID refuse to write it, so `droppedFields` is an accurate account of
798
+ * the request. But "was not saved" is only news when something was actually
799
+ * lost, and the console's edit form used to round-trip the whole record —
800
+ * including fields it had itself rendered as read-only text — so every save of
801
+ * a state-locked record raised a warning listing fields the user never touched
802
+ * and whose values never changed.
803
+ *
804
+ * A field is kept (still warned about) whenever the no-op cannot be PROVEN:
805
+ * the response echoed no record, or that record does not carry the key.
806
+ *
807
+ * Returns entries with empty `fields` removed; an all-no-op event list comes
808
+ * back empty, which suppresses the warning entirely.
809
+ */
810
+ function withoutNoOpDrops(
811
+ droppedFields: DroppedFieldsEvent[],
812
+ sent: Record<string, unknown> | undefined | null,
813
+ stored: Record<string, unknown> | undefined | null,
814
+ ): DroppedFieldsEvent[] {
815
+ if (!sent || !stored || typeof sent !== 'object' || typeof stored !== 'object') {
816
+ return droppedFields;
817
+ }
818
+ const out: DroppedFieldsEvent[] = [];
819
+ for (const e of droppedFields) {
820
+ const kept = e.fields.filter((f) => {
821
+ if (!Object.prototype.hasOwnProperty.call(sent, f)) return true;
822
+ if (!Object.prototype.hasOwnProperty.call(stored, f)) return true;
823
+ return !sameWireValue(
824
+ (sent as Record<string, unknown>)[f],
825
+ (stored as Record<string, unknown>)[f],
826
+ );
827
+ });
828
+ if (kept.length > 0) out.push({ ...e, fields: kept });
829
+ }
830
+ return out;
831
+ }
832
+
768
833
  /**
769
834
  * ObjectStack Data Source Adapter
770
- *
835
+ *
771
836
  * Bridges the ObjectStack Client SDK with the ObjectUI DataSource interface.
772
837
  * This allows Object UI applications to seamlessly integrate with ObjectStack
773
838
  * backends while maintaining the universal DataSource abstraction.
@@ -1303,13 +1368,17 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1303
1368
  resource: string,
1304
1369
  result: unknown,
1305
1370
  id?: string | number,
1371
+ sent?: Record<string, unknown> | null,
1306
1372
  ): void {
1307
1373
  const dropped = (result as { droppedFields?: unknown } | null | undefined)?.droppedFields;
1308
1374
  if (!Array.isArray(dropped) || dropped.length === 0) return;
1309
- const droppedFields = dropped.filter(
1375
+ const valid = dropped.filter(
1310
1376
  (e): e is DroppedFieldsEvent =>
1311
1377
  !!e && typeof e === 'object' && Array.isArray((e as DroppedFieldsEvent).fields) && (e as DroppedFieldsEvent).fields.length > 0,
1312
1378
  );
1379
+ // A strip that changed nothing is not news — see withoutNoOpDrops (#3484).
1380
+ const stored = (result as { record?: Record<string, unknown> } | null | undefined)?.record;
1381
+ const droppedFields = withoutNoOpDrops(valid, sent, stored);
1313
1382
  if (droppedFields.length === 0) return;
1314
1383
  this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields });
1315
1384
  }
@@ -1332,11 +1401,25 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1332
1401
  ): void {
1333
1402
  const dropped = (payload as { droppedFields?: unknown } | null | undefined)?.droppedFields;
1334
1403
  if (!Array.isArray(dropped) || dropped.length === 0) return;
1404
+ const results = (payload as { results?: unknown[] } | null | undefined)?.results;
1335
1405
  for (const entry of dropped) {
1336
1406
  if (!entry || typeof entry !== 'object') continue;
1337
1407
  const e = entry as DroppedFieldsEvent & { index?: number };
1338
1408
  if (!Array.isArray(e.fields) || e.fields.length === 0) continue;
1339
1409
  const op = typeof e.index === 'number' ? operations[e.index] : undefined;
1410
+ // Same no-op suppression as the single-record path (#3484). The echoed
1411
+ // row for the originating op is the "stored" side; when the batch echoed
1412
+ // nothing usable, `withoutNoOpDrops` keeps every field.
1413
+ const stored =
1414
+ typeof e.index === 'number' && Array.isArray(results)
1415
+ ? (results[e.index] as Record<string, unknown> | undefined)
1416
+ : undefined;
1417
+ const [live] = withoutNoOpDrops(
1418
+ [{ object: e.object, fields: e.fields, reason: e.reason }],
1419
+ op?.data as Record<string, unknown> | undefined,
1420
+ stored,
1421
+ );
1422
+ if (!live) continue;
1340
1423
  // `delete` never drops fields; anything unexpected reads as an update,
1341
1424
  // which is the truthful default for a batch that echoed a strip.
1342
1425
  const operation: 'create' | 'update' = (op?.action ?? 'create') === 'create' ? 'create' : 'update';
@@ -1344,7 +1427,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1344
1427
  operation,
1345
1428
  resource: e.object ?? op?.object ?? '',
1346
1429
  ...(op?.id !== undefined && op?.id !== null ? { id: op.id } : {}),
1347
- droppedFields: [{ object: e.object, fields: e.fields, reason: e.reason }],
1430
+ droppedFields: [live],
1348
1431
  });
1349
1432
  }
1350
1433
  }
@@ -1366,7 +1449,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1366
1449
  try {
1367
1450
  const result = await this.client.data.create<T>(resource, data);
1368
1451
  this.emitMutation({ type: 'create', resource, record: { ...result.record } });
1369
- this.notifyDroppedFields('create', resource, result, (result.record as { id?: string | number } | undefined)?.id);
1452
+ this.notifyDroppedFields(
1453
+ 'create',
1454
+ resource,
1455
+ result,
1456
+ (result.record as { id?: string | number } | undefined)?.id,
1457
+ data as Record<string, unknown>,
1458
+ );
1370
1459
  return result.record;
1371
1460
  } catch (err) {
1372
1461
  // `update` has always normalised; `create` did not, so a rejected insert
@@ -1403,7 +1492,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1403
1492
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
1404
1493
  );
1405
1494
  this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
1406
- this.notifyDroppedFields('update', resource, result, id);
1495
+ this.notifyDroppedFields('update', resource, result, id, data as Record<string, unknown>);
1407
1496
  return result.record;
1408
1497
  } catch (err) {
1409
1498
  throw normaliseClientError(err);
@@ -3511,14 +3600,14 @@ export {
3511
3600
  BulkOperationError,
3512
3601
  ConnectionError,
3513
3602
  AuthenticationError,
3514
- ValidationError,
3603
+ DataApiValidationError,
3515
3604
  createErrorFromResponse,
3516
3605
  isObjectStackError,
3517
3606
  isErrorType,
3518
3607
  } from './errors';
3519
3608
 
3520
3609
  // Export cache types
3521
- export type { CacheStats } from './cache/MetadataCache';
3610
+ export type { MetadataCacheStats } from './cache/MetadataCache';
3522
3611
 
3523
3612
  // v3.0.0 Deep Integration modules
3524
3613
  export { CloudOperations } from './cloud';
@@ -3546,7 +3635,7 @@ export type {
3546
3635
  MetadataClientConfig,
3547
3636
  MetadataListOptions,
3548
3637
  MetadataDraftHeader,
3549
- MetadataSaveOptions,
3638
+ MetadataClientSaveOptions,
3550
3639
  MetadataGetOptions,
3551
3640
  MetadataDeleteOptions,
3552
3641
  MetadataHistoryOptions,
@@ -3563,7 +3652,7 @@ export type {
3563
3652
  } from './metadata-client';
3564
3653
 
3565
3654
  export { SecurityManager } from './security';
3566
- export type { SecurityPolicy, CSPConfig, AuditLogConfig, AuditEventType, DataMaskingConfig, DataMaskingRule, AuditLogEntry } from './security';
3655
+ export type { SecurityManagerPolicy, CSPConfig, AuditLogConfig, AuditEventType, DataMaskingConfig, DataMaskingRule, AuditLogEntry } from './security';
3567
3656
 
3568
3657
  export { createDefaultCanvasConfig, snapToGrid, calculateAutoLayout } from './studio';
3569
3658
  export type { StudioCanvasConfig, StudioPropertyEditor, StudioThemeBuilderConfig, StudioColorPalette, StudioTypographyPreset, StudioShadowPreset } from './studio';
@@ -75,7 +75,21 @@ export interface MetadataDraftHeader {
75
75
  updatedBy: string | null;
76
76
  }
77
77
 
78
- export interface MetadataSaveOptions {
78
+ /**
79
+ * Options for {@link MetadataClient.save} — a WRITE OVER HTTP to
80
+ * `/api/v1/meta/:type/:name`.
81
+ *
82
+ * NOT the spec's `MetadataSaveOptions` (`@objectstack/spec/system` and
83
+ * `/kernel`), whose name this interface wore until objectui#3160
84
+ * (objectstack#4115 ledger batch 6). Both spec copies describe writing a
85
+ * metadata item to a FILE — `format: json|yaml|ts`, `path`, `indent`,
86
+ * `prettify`, `sortKeys`, `backup`, `atomic`, `loader`. Not one of those keys
87
+ * exists here, and not one of these exists there: this is the REST client's
88
+ * request envelope — optimistic concurrency (`ifMatch` → `If-Match`), actor
89
+ * attribution, the destructive-change override, the ADR-0033 draft/publish
90
+ * mode, and the owning package. Same words, different layer.
91
+ */
92
+ export interface MetadataClientSaveOptions {
79
93
  /**
80
94
  * Optimistic concurrency token (the `checksum` returned by the last
81
95
  * read). When present, sent as the `If-Match` header so concurrent
@@ -122,7 +136,7 @@ export interface MetadataGetOptions {
122
136
  packageId?: string;
123
137
  }
124
138
 
125
- export interface MetadataDeleteOptions extends MetadataSaveOptions {
139
+ export interface MetadataDeleteOptions extends MetadataClientSaveOptions {
126
140
  /**
127
141
  * Target state. `'draft'` discards the pending draft (keeps the
128
142
  * published overlay intact). Omit to reset the active overlay back
@@ -527,7 +541,7 @@ export class MetadataClient {
527
541
  type: string,
528
542
  name: string,
529
543
  item: unknown,
530
- options: MetadataSaveOptions = {},
544
+ options: MetadataClientSaveOptions = {},
531
545
  ): Promise<T> {
532
546
  if (!name || !String(name).trim()) {
533
547
  // The `PUT /meta/:type/:name` route rejects a missing `:name` segment
@@ -157,6 +157,107 @@ describe('ObjectStackAdapter.onWriteWarning', () => {
157
157
  expect(events).toHaveLength(1);
158
158
  });
159
159
 
160
+ // ── no-op strips are not news (objectui#3484) ─────────────────────────────
161
+ //
162
+ // The server is right to report a strip — the client DID send the key. But
163
+ // "was not saved" is only worth a toast when something was LOST. The console
164
+ // edit form used to round-trip the whole record, so every save of a
165
+ // state-locked record warned about fields the user never touched whose values
166
+ // never changed. When the echoed record proves the stripped value equals what
167
+ // was sent, stay quiet.
168
+
169
+ it('suppresses the warning when every stripped value equals what the record already holds', async () => {
170
+ const update = vi.fn().mockResolvedValue({
171
+ record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment', title: 'B' },
172
+ droppedFields: [
173
+ { object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
174
+ ],
175
+ });
176
+ const ds = makeDS({ update });
177
+ const events: WriteWarningEvent[] = [];
178
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
179
+
180
+ await ds.update('andon', 'r1', {
181
+ title: 'B',
182
+ type: 'quality_abnormal',
183
+ source_method: 'qc_judgment',
184
+ });
185
+
186
+ expect(events).toEqual([]);
187
+ });
188
+
189
+ it('still warns about the fields that really did change, and only those', async () => {
190
+ const update = vi.fn().mockResolvedValue({
191
+ record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment' },
192
+ droppedFields: [
193
+ { object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
194
+ ],
195
+ });
196
+ const ds = makeDS({ update });
197
+ const events: WriteWarningEvent[] = [];
198
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
199
+
200
+ // The user really did try to change `type`; `source_method` round-tripped.
201
+ await ds.update('andon', 'r1', { type: 'equipment_fault', source_method: 'qc_judgment' });
202
+
203
+ expect(events).toEqual([
204
+ {
205
+ operation: 'update',
206
+ resource: 'andon',
207
+ id: 'r1',
208
+ droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
209
+ },
210
+ ]);
211
+ });
212
+
213
+ it('keeps the warning when the no-op cannot be PROVEN (record echo lacks the key)', async () => {
214
+ const update = vi.fn().mockResolvedValue({
215
+ record: { id: 'r1' },
216
+ droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
217
+ });
218
+ const ds = makeDS({ update });
219
+ const events: WriteWarningEvent[] = [];
220
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
221
+
222
+ await ds.update('andon', 'r1', { type: 'quality_abnormal' });
223
+
224
+ expect(events).toHaveLength(1);
225
+ });
226
+
227
+ it('does not treat a type change as a no-op (1 is not "1")', async () => {
228
+ const update = vi.fn().mockResolvedValue({
229
+ record: { id: 'r1', tax_rate: '9' },
230
+ droppedFields: [{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' }],
231
+ });
232
+ const ds = makeDS({ update });
233
+ const events: WriteWarningEvent[] = [];
234
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
235
+
236
+ await ds.update('invoice', 'r1', { tax_rate: 9 });
237
+
238
+ expect(events).toHaveLength(1);
239
+ });
240
+
241
+ it('suppresses a no-op strip on the cross-object batch path too', async () => {
242
+ const batchTransaction = vi.fn().mockResolvedValue({
243
+ results: [{ id: 'acc1' }, { id: 'inv1', tax_rate: 9 }],
244
+ droppedFields: [
245
+ { object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
246
+ ],
247
+ });
248
+ const ds = makeDS({ batchTransaction });
249
+ ds.atomicBatchCapability = true;
250
+ const events: WriteWarningEvent[] = [];
251
+ ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
252
+
253
+ await ds.batchTransaction([
254
+ { object: 'account', action: 'create', data: { name: 'Acme' } },
255
+ { object: 'invoice', action: 'update', id: 'inv1', data: { status: 'paid', tax_rate: 9 } },
256
+ ]);
257
+
258
+ expect(events).toEqual([]);
259
+ });
260
+
160
261
  it('isolates a throwing listener so the write still resolves and other listeners fire', async () => {
161
262
  const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
162
263
  const ds = makeDS({ update });
package/src/security.ts CHANGED
@@ -11,7 +11,20 @@
11
11
  * Provides advanced security policies: CSP config, audit logging, data masking.
12
12
  */
13
13
 
14
- export interface SecurityPolicy {
14
+ /**
15
+ * Configuration for {@link SecurityManager} — this adapter's browser-side
16
+ * security posture: the CSP header it generates, the in-memory audit log it
17
+ * keeps, and the field-masking rules it applies before a record is rendered.
18
+ *
19
+ * NOT the spec's `SecurityPolicy` (`@objectstack/spec/kernel`), whose name this
20
+ * interface wore until objectui#3160 (objectstack#4115 ledger batch 6). That one
21
+ * is the PACKAGE SUPPLY-CHAIN policy — `{ id, name, autoScan, thresholds,
22
+ * allowedLicenses, prohibitedLicenses, codeSigning, sandbox }` — the rules a
23
+ * marketplace applies when scanning a plugin before install. It shares no key
24
+ * with this one, so there was nothing to derive; the collision is the word
25
+ * "security", not the concept.
26
+ */
27
+ export interface SecurityManagerPolicy {
15
28
  /** Content Security Policy configuration */
16
29
  csp?: CSPConfig;
17
30
  /** Audit logging configuration */
@@ -100,10 +113,10 @@ export interface AuditLogEntry {
100
113
  * Handles CSP generation, audit logging, and data masking.
101
114
  */
102
115
  export class SecurityManager {
103
- private policy: SecurityPolicy;
116
+ private policy: SecurityManagerPolicy;
104
117
  private auditLog: AuditLogEntry[] = [];
105
118
 
106
- constructor(policy: SecurityPolicy = {}) {
119
+ constructor(policy: SecurityManagerPolicy = {}) {
107
120
  this.policy = policy;
108
121
  }
109
122
 
@@ -204,14 +217,14 @@ export class SecurityManager {
204
217
  /**
205
218
  * Update the security policy.
206
219
  */
207
- updatePolicy(policy: Partial<SecurityPolicy>): void {
220
+ updatePolicy(policy: Partial<SecurityManagerPolicy>): void {
208
221
  this.policy = { ...this.policy, ...policy };
209
222
  }
210
223
 
211
224
  /**
212
225
  * Get current security policy.
213
226
  */
214
- getPolicy(): SecurityPolicy {
227
+ getPolicy(): SecurityManagerPolicy {
215
228
  return { ...this.policy };
216
229
  }
217
230
  }