@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/CHANGELOG.md +228 -0
- package/README.md +6 -1
- package/dist/index.cjs +56 -14
- package/dist/index.d.cts +66 -28
- package/dist/index.d.ts +66 -28
- package/dist/index.js +55 -13
- package/package.json +5 -5
- package/src/cache/MetadataCache.ts +12 -3
- package/src/errors.test.ts +12 -12
- package/src/errors.ts +23 -5
- package/src/index.ts +110 -21
- package/src/metadata-client.ts +17 -3
- package/src/onWriteWarning.test.ts +101 -0
- package/src/security.ts +18 -5
- package/src/spec-symbol-batch6.test.ts +306 -0
- package/src/validation-error.test.ts +9 -9
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
|
-
|
|
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
|
|
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
|
|
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.).
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
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
|
|
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
|
|
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: [
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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';
|
package/src/metadata-client.ts
CHANGED
|
@@ -75,7 +75,21 @@ export interface MetadataDraftHeader {
|
|
|
75
75
|
updatedBy: string | null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
116
|
+
private policy: SecurityManagerPolicy;
|
|
104
117
|
private auditLog: AuditLogEntry[] = [];
|
|
105
118
|
|
|
106
|
-
constructor(policy:
|
|
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<
|
|
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():
|
|
227
|
+
getPolicy(): SecurityManagerPolicy {
|
|
215
228
|
return { ...this.policy };
|
|
216
229
|
}
|
|
217
230
|
}
|