@verdant-web/store 4.3.0 → 4.4.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/dist/bundle/index.js +6 -6
- package/dist/bundle/index.js.map +4 -4
- package/dist/esm/__tests__/entities.test.js +296 -1
- package/dist/esm/__tests__/entities.test.js.map +1 -1
- package/dist/esm/client/Client.js +2 -1
- package/dist/esm/client/Client.js.map +1 -1
- package/dist/esm/entities/Entity.d.ts +6 -2
- package/dist/esm/entities/Entity.js +96 -39
- package/dist/esm/entities/Entity.js.map +1 -1
- package/dist/esm/entities/Entity.test.js +4 -3
- package/dist/esm/entities/Entity.test.js.map +1 -1
- package/dist/esm/entities/EntityCache.d.ts +6 -4
- package/dist/esm/entities/EntityCache.js +18 -7
- package/dist/esm/entities/EntityCache.js.map +1 -1
- package/dist/esm/entities/EntityMetadata.d.ts +6 -0
- package/dist/esm/entities/EntityMetadata.js +52 -4
- package/dist/esm/entities/EntityMetadata.js.map +1 -1
- package/dist/esm/entities/EntityStore.js +4 -1
- package/dist/esm/entities/EntityStore.js.map +1 -1
- package/dist/esm/files/EntityFile.d.ts +6 -1
- package/dist/esm/files/EntityFile.js +11 -4
- package/dist/esm/files/EntityFile.js.map +1 -1
- package/dist/esm/files/FileManager.d.ts +3 -1
- package/dist/esm/files/FileManager.js +2 -2
- package/dist/esm/files/FileManager.js.map +1 -1
- package/dist/esm/utils/versions.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/entities.test.ts +311 -0
- package/src/client/Client.ts +6 -1
- package/src/entities/Entity.test.ts +8 -7
- package/src/entities/Entity.ts +117 -38
- package/src/entities/EntityCache.ts +20 -10
- package/src/entities/EntityMetadata.ts +64 -5
- package/src/entities/EntityStore.ts +5 -1
- package/src/files/EntityFile.ts +16 -3
- package/src/files/FileManager.ts +7 -3
- package/src/utils/versions.ts +1 -1
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
assert,
|
|
3
3
|
createRef,
|
|
4
4
|
EventSubscriber,
|
|
5
|
+
FixedTimestampProvider,
|
|
5
6
|
NaiveTimestampProvider,
|
|
6
7
|
PatchCreator,
|
|
7
8
|
schema,
|
|
@@ -10,6 +11,7 @@ import { describe, expect, it, MockedFunction, vi, vitest } from 'vitest';
|
|
|
10
11
|
import { WeakEvent } from 'weak-event';
|
|
11
12
|
import { Time } from '../context/Time.js';
|
|
12
13
|
import { EntityFamilyMetadata } from '../entities/EntityMetadata.js';
|
|
14
|
+
import { MARK_FAILED } from '../files/EntityFile.js';
|
|
13
15
|
import { Entity, EntityFile } from '../index.js';
|
|
14
16
|
import { createTestStorage } from './fixtures/testStorage.js';
|
|
15
17
|
|
|
@@ -734,6 +736,7 @@ describe('entities', () => {
|
|
|
734
736
|
files: {
|
|
735
737
|
add: vi.fn(),
|
|
736
738
|
},
|
|
739
|
+
weakRef: (v: any) => new WeakRef(v),
|
|
737
740
|
} as any;
|
|
738
741
|
const metadataFamily = new EntityFamilyMetadata({
|
|
739
742
|
ctx: testCtx,
|
|
@@ -860,4 +863,312 @@ describe('entities', () => {
|
|
|
860
863
|
expect(entity.getSnapshot()).toEqual(entity.getUnprunedSnapshot());
|
|
861
864
|
expect(entity.deepInvalid).toBe(false);
|
|
862
865
|
});
|
|
866
|
+
|
|
867
|
+
it('should only fire deep change once for a file change even if you get it multiple times', async () => {
|
|
868
|
+
// addressing a specific bug / lazy thing I did that caused a listener leak
|
|
869
|
+
const storage = await createTestStorage();
|
|
870
|
+
const weird = await storage.weirds.put({});
|
|
871
|
+
const fileList = weird.get('fileList');
|
|
872
|
+
const file = new window.File(['d(⌐□_□)b'], 'test.txt', {
|
|
873
|
+
type: 'text/plain',
|
|
874
|
+
});
|
|
875
|
+
fileList.push(file);
|
|
876
|
+
const deepListener = vi.fn();
|
|
877
|
+
fileList.subscribe('changeDeep', deepListener);
|
|
878
|
+
fileList.get(0);
|
|
879
|
+
fileList.get(0);
|
|
880
|
+
fileList.get(0);
|
|
881
|
+
// easy way to trigger change
|
|
882
|
+
fileList.get(0)[MARK_FAILED]();
|
|
883
|
+
expect(deepListener).toHaveBeenCalledTimes(1);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('should correctly prune deep nested objects', () => {
|
|
887
|
+
// manually constructing an entity for this test is easiest,
|
|
888
|
+
// kind of hard to force invalid data otherwise
|
|
889
|
+
const onPendingOperations = vi.fn();
|
|
890
|
+
const time = new Time(new FixedTimestampProvider(), 1);
|
|
891
|
+
// too much junk in here, have to manually pick and choose
|
|
892
|
+
let subId = 100; // avoid false-flag matching of other ids by choosing an arbitrary start point
|
|
893
|
+
const testCtx = {
|
|
894
|
+
globalEvents: new EventSubscriber(),
|
|
895
|
+
time,
|
|
896
|
+
log: console.log,
|
|
897
|
+
patchCreator: new PatchCreator(
|
|
898
|
+
() => time.now,
|
|
899
|
+
() => `${subId++}`,
|
|
900
|
+
),
|
|
901
|
+
files: {
|
|
902
|
+
add: vi.fn(),
|
|
903
|
+
},
|
|
904
|
+
weakRef: (v: any) => new WeakRef(v),
|
|
905
|
+
} as any;
|
|
906
|
+
const metadataFamily = new EntityFamilyMetadata({
|
|
907
|
+
ctx: testCtx,
|
|
908
|
+
onPendingOperations,
|
|
909
|
+
rootOid: 'foos/a',
|
|
910
|
+
});
|
|
911
|
+
const entity = new Entity({
|
|
912
|
+
oid: 'foos/a',
|
|
913
|
+
schema: {
|
|
914
|
+
type: 'object',
|
|
915
|
+
properties: {
|
|
916
|
+
id: schema.fields.id(),
|
|
917
|
+
// should prune to an empty list entity
|
|
918
|
+
listTest: schema.fields.array({
|
|
919
|
+
items: schema.fields.object({
|
|
920
|
+
fields: {
|
|
921
|
+
required: schema.fields.string(),
|
|
922
|
+
},
|
|
923
|
+
}),
|
|
924
|
+
}),
|
|
925
|
+
objectTest: schema.fields.object({
|
|
926
|
+
default: () => ({
|
|
927
|
+
// @ts-ignore - excessive nesting
|
|
928
|
+
nested: {
|
|
929
|
+
required: 'foo',
|
|
930
|
+
},
|
|
931
|
+
}),
|
|
932
|
+
fields: {
|
|
933
|
+
nested: schema.fields.object({
|
|
934
|
+
fields: {
|
|
935
|
+
required: schema.fields.string(),
|
|
936
|
+
},
|
|
937
|
+
}),
|
|
938
|
+
},
|
|
939
|
+
}),
|
|
940
|
+
// this one will not be present at all.
|
|
941
|
+
missingTest: schema.fields.object({
|
|
942
|
+
default: () => ({
|
|
943
|
+
value: 1,
|
|
944
|
+
}),
|
|
945
|
+
fields: {
|
|
946
|
+
value: schema.fields.number(),
|
|
947
|
+
},
|
|
948
|
+
}),
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
ctx: testCtx,
|
|
952
|
+
deleteSelf: vi.fn(),
|
|
953
|
+
files: {
|
|
954
|
+
add: vi.fn(),
|
|
955
|
+
} as any,
|
|
956
|
+
metadataFamily,
|
|
957
|
+
storeEvents: {
|
|
958
|
+
add: new WeakEvent(),
|
|
959
|
+
replace: new WeakEvent(),
|
|
960
|
+
resetAll: new WeakEvent(),
|
|
961
|
+
},
|
|
962
|
+
readonlyKeys: ['id'],
|
|
963
|
+
});
|
|
964
|
+
metadataFamily.addConfirmedData({
|
|
965
|
+
baselines: [
|
|
966
|
+
{
|
|
967
|
+
// an invalid list item
|
|
968
|
+
oid: 'foos/a:1',
|
|
969
|
+
snapshot: {},
|
|
970
|
+
timestamp: time.now,
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
oid: 'foos/a:2',
|
|
974
|
+
snapshot: [createRef('foos/a:1')],
|
|
975
|
+
timestamp: time.now,
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
// an invalid nested object
|
|
979
|
+
oid: 'foos/a:3',
|
|
980
|
+
snapshot: {},
|
|
981
|
+
timestamp: time.now,
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
oid: 'foos/a:4',
|
|
985
|
+
snapshot: { nested: createRef('foos/a:3') },
|
|
986
|
+
timestamp: time.now,
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
oid: 'foos/a',
|
|
990
|
+
snapshot: {
|
|
991
|
+
id: 'a',
|
|
992
|
+
listTest: createRef('foos/a:2'),
|
|
993
|
+
objectTest: createRef('foos/a:4'),
|
|
994
|
+
},
|
|
995
|
+
timestamp: time.now,
|
|
996
|
+
},
|
|
997
|
+
],
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
expect(entity.deepInvalid).toBe(true);
|
|
1001
|
+
expect(entity.getSnapshot()).toEqual({
|
|
1002
|
+
id: 'a',
|
|
1003
|
+
listTest: [],
|
|
1004
|
+
// gets the default, pruned
|
|
1005
|
+
objectTest: {
|
|
1006
|
+
nested: {
|
|
1007
|
+
required: 'foo',
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
missingTest: {
|
|
1011
|
+
value: 1,
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// the kicker...
|
|
1016
|
+
const list = entity.get('listTest');
|
|
1017
|
+
expect(list).toBeInstanceOf(Entity);
|
|
1018
|
+
const obj = entity.get('objectTest');
|
|
1019
|
+
expect(obj).toBeInstanceOf(Entity);
|
|
1020
|
+
const missing = entity.get('missingTest');
|
|
1021
|
+
expect(missing).toBeInstanceOf(Entity);
|
|
1022
|
+
|
|
1023
|
+
// we should be able to make edits to the pruned versions
|
|
1024
|
+
// and they get committed.
|
|
1025
|
+
list.push({ required: 'foo' });
|
|
1026
|
+
expect(entity.getSnapshot()).toEqual({
|
|
1027
|
+
id: 'a',
|
|
1028
|
+
listTest: [{ required: 'foo' }],
|
|
1029
|
+
objectTest: {
|
|
1030
|
+
nested: {
|
|
1031
|
+
required: 'foo',
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
missingTest: {
|
|
1035
|
+
value: 1,
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
obj.get('nested').set('required', 'bar');
|
|
1039
|
+
expect(entity.getSnapshot()).toEqual({
|
|
1040
|
+
id: 'a',
|
|
1041
|
+
listTest: [{ required: 'foo' }],
|
|
1042
|
+
objectTest: {
|
|
1043
|
+
nested: {
|
|
1044
|
+
required: 'bar',
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
missingTest: {
|
|
1048
|
+
value: 1,
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
missing.set('value', 2);
|
|
1052
|
+
expect(entity.getSnapshot()).toEqual({
|
|
1053
|
+
id: 'a',
|
|
1054
|
+
listTest: [{ required: 'foo' }],
|
|
1055
|
+
objectTest: {
|
|
1056
|
+
nested: {
|
|
1057
|
+
required: 'bar',
|
|
1058
|
+
},
|
|
1059
|
+
// gets the default, pruned
|
|
1060
|
+
},
|
|
1061
|
+
missingTest: {
|
|
1062
|
+
value: 2,
|
|
1063
|
+
},
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
expect(onPendingOperations).toHaveBeenCalledTimes(5);
|
|
1067
|
+
// when doing those changes above, we see pending operations
|
|
1068
|
+
// added to construct the pruned versions with applied changes
|
|
1069
|
+
function removeExtra(obj: any) {
|
|
1070
|
+
delete obj.timestamp;
|
|
1071
|
+
delete obj.authz;
|
|
1072
|
+
return obj;
|
|
1073
|
+
}
|
|
1074
|
+
// being kind of precious about assertions here since
|
|
1075
|
+
// ordering is not quite as important and some ops create new
|
|
1076
|
+
// oids.
|
|
1077
|
+
const firstCall = onPendingOperations.mock.calls[0][0].map(removeExtra);
|
|
1078
|
+
|
|
1079
|
+
// the required field was set to the default
|
|
1080
|
+
expect(firstCall).toContainEqual({
|
|
1081
|
+
data: {
|
|
1082
|
+
name: 'required',
|
|
1083
|
+
op: 'set',
|
|
1084
|
+
value: 'foo',
|
|
1085
|
+
},
|
|
1086
|
+
oid: 'foos/a:3',
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// missingTest is created wholesale
|
|
1090
|
+
expect(firstCall).toContainEqual({
|
|
1091
|
+
data: {
|
|
1092
|
+
op: 'set',
|
|
1093
|
+
name: 'missingTest',
|
|
1094
|
+
value: {
|
|
1095
|
+
'@@type': 'ref',
|
|
1096
|
+
id: expect.stringMatching(/^foos\/a:/),
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
oid: 'foos/a',
|
|
1100
|
+
});
|
|
1101
|
+
expect(firstCall).toContainEqual({
|
|
1102
|
+
data: {
|
|
1103
|
+
op: 'initialize',
|
|
1104
|
+
value: {
|
|
1105
|
+
value: 1,
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
oid: expect.stringMatching(/^foos\/a:/),
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// we asserted all the contents, now also assert the length
|
|
1112
|
+
expect(firstCall).toHaveLength(3);
|
|
1113
|
+
|
|
1114
|
+
const secondCall = onPendingOperations.mock.calls[1][0].map(removeExtra);
|
|
1115
|
+
// the invalid item was removed from the array
|
|
1116
|
+
expect(secondCall).toContainEqual({
|
|
1117
|
+
data: {
|
|
1118
|
+
op: 'delete',
|
|
1119
|
+
},
|
|
1120
|
+
oid: 'foos/a:1',
|
|
1121
|
+
});
|
|
1122
|
+
expect(secondCall).toContainEqual({
|
|
1123
|
+
data: {
|
|
1124
|
+
op: 'list-remove',
|
|
1125
|
+
only: 'last',
|
|
1126
|
+
value: {
|
|
1127
|
+
'@@type': 'ref',
|
|
1128
|
+
id: 'foos/a:1',
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
oid: 'foos/a:2',
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
const thirdCall = onPendingOperations.mock.calls[2][0].map(removeExtra);
|
|
1135
|
+
// we can be more lax here
|
|
1136
|
+
expect(thirdCall).toMatchInlineSnapshot(`
|
|
1137
|
+
[
|
|
1138
|
+
{
|
|
1139
|
+
"data": {
|
|
1140
|
+
"op": "initialize",
|
|
1141
|
+
"value": {
|
|
1142
|
+
"required": "foo",
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
"oid": "foos/a:100",
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
"data": {
|
|
1149
|
+
"op": "list-push",
|
|
1150
|
+
"value": {
|
|
1151
|
+
"@@type": "ref",
|
|
1152
|
+
"id": "foos/a:100",
|
|
1153
|
+
},
|
|
1154
|
+
},
|
|
1155
|
+
"oid": "foos/a:2",
|
|
1156
|
+
},
|
|
1157
|
+
]
|
|
1158
|
+
`);
|
|
1159
|
+
|
|
1160
|
+
const fourthCall = onPendingOperations.mock.calls[3][0].map(removeExtra);
|
|
1161
|
+
expect(fourthCall).toMatchInlineSnapshot(`
|
|
1162
|
+
[
|
|
1163
|
+
{
|
|
1164
|
+
"data": {
|
|
1165
|
+
"name": "required",
|
|
1166
|
+
"op": "set",
|
|
1167
|
+
"value": "bar",
|
|
1168
|
+
},
|
|
1169
|
+
"oid": "foos/a:3",
|
|
1170
|
+
},
|
|
1171
|
+
]
|
|
1172
|
+
`);
|
|
1173
|
+
});
|
|
863
1174
|
});
|
package/src/client/Client.ts
CHANGED
|
@@ -205,13 +205,18 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
|
|
|
205
205
|
return await this._entities.addData(data);
|
|
206
206
|
}
|
|
207
207
|
} catch (err) {
|
|
208
|
-
this.context.log(
|
|
208
|
+
this.context.log(
|
|
209
|
+
'critical',
|
|
210
|
+
'Sync failed. To avoid data corruption, the client will now shut down.',
|
|
211
|
+
err,
|
|
212
|
+
);
|
|
209
213
|
this.emit(
|
|
210
214
|
'developerError',
|
|
211
215
|
new Error('Sync failed, see logs or cause', {
|
|
212
216
|
cause: err,
|
|
213
217
|
}),
|
|
214
218
|
);
|
|
219
|
+
await this.close();
|
|
215
220
|
throw err;
|
|
216
221
|
}
|
|
217
222
|
};
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { Entity } from './Entity.js';
|
|
3
|
-
import { EntityFamilyMetadata } from './EntityMetadata.js';
|
|
4
|
-
import { Context } from '../context/context.js';
|
|
5
|
-
import { EntityStoreEvents } from './EntityStore.js';
|
|
6
|
-
import { WeakEvent } from 'weak-event';
|
|
7
|
-
import { FileManager } from '../files/FileManager.js';
|
|
8
1
|
import {
|
|
9
2
|
NaiveTimestampProvider,
|
|
10
3
|
PatchCreator,
|
|
11
4
|
groupPatchesByOid,
|
|
12
5
|
} from '@verdant-web/common';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { WeakEvent } from 'weak-event';
|
|
8
|
+
import { Context } from '../context/context.js';
|
|
13
9
|
import { Time } from '../context/Time.js';
|
|
10
|
+
import { FileManager } from '../files/FileManager.js';
|
|
11
|
+
import { Entity } from './Entity.js';
|
|
12
|
+
import { EntityFamilyMetadata } from './EntityMetadata.js';
|
|
13
|
+
import { EntityStoreEvents } from './EntityStore.js';
|
|
14
14
|
|
|
15
15
|
describe('Entity', () => {
|
|
16
16
|
const schema = {
|
|
@@ -65,6 +65,7 @@ describe('Entity', () => {
|
|
|
65
65
|
log: vi.fn(),
|
|
66
66
|
time: new Time(time, 1),
|
|
67
67
|
patchCreator,
|
|
68
|
+
weakRef: (v: any) => new WeakRef(v),
|
|
68
69
|
};
|
|
69
70
|
const entity = new Entity({
|
|
70
71
|
oid: 'test/1',
|
package/src/entities/Entity.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
compareRefs,
|
|
11
11
|
createFileRef,
|
|
12
12
|
createRef,
|
|
13
|
+
createSubOid,
|
|
13
14
|
getChildFieldSchema,
|
|
14
15
|
getFieldDefault,
|
|
15
16
|
hasDefault,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
isNullable,
|
|
19
20
|
isObject,
|
|
20
21
|
isObjectRef,
|
|
22
|
+
isPrimitive,
|
|
21
23
|
isRef,
|
|
22
24
|
maybeGetOid,
|
|
23
25
|
memoByKeys,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
validateEntityField,
|
|
26
28
|
} from '@verdant-web/common';
|
|
27
29
|
import { Context } from '../context/context.js';
|
|
30
|
+
import { CHILD_FILE_CHANGED } from '../files/EntityFile.js';
|
|
28
31
|
import { FileManager } from '../files/FileManager.js';
|
|
29
32
|
import { processValueFiles } from '../files/utils.js';
|
|
30
33
|
import { ClientWithCollections, EntityFile } from '../index.js';
|
|
@@ -112,6 +115,7 @@ export class Entity<
|
|
|
112
115
|
files,
|
|
113
116
|
storeEvents,
|
|
114
117
|
deleteSelf,
|
|
118
|
+
fieldPath,
|
|
115
119
|
}: EntityInit) {
|
|
116
120
|
super();
|
|
117
121
|
|
|
@@ -122,10 +126,12 @@ export class Entity<
|
|
|
122
126
|
this.ctx = ctx;
|
|
123
127
|
this.files = files;
|
|
124
128
|
this.schema = schema;
|
|
129
|
+
this.fieldPath = fieldPath || [];
|
|
125
130
|
this.entityFamily =
|
|
126
131
|
childCache ||
|
|
127
132
|
new EntityCache({
|
|
128
133
|
initial: [this],
|
|
134
|
+
ctx,
|
|
129
135
|
});
|
|
130
136
|
this.metadataFamily = metadataFamily;
|
|
131
137
|
this.storeEvents = storeEvents;
|
|
@@ -213,8 +219,8 @@ export class Entity<
|
|
|
213
219
|
return null as any;
|
|
214
220
|
}
|
|
215
221
|
|
|
216
|
-
|
|
217
|
-
assignOid(
|
|
222
|
+
let newView: any = this.isList ? [] : {};
|
|
223
|
+
assignOid(newView, this.oid);
|
|
218
224
|
|
|
219
225
|
if (Array.isArray(rawView)) {
|
|
220
226
|
const schema = getChildFieldSchema(this.schema, 0);
|
|
@@ -243,7 +249,7 @@ export class Entity<
|
|
|
243
249
|
|
|
244
250
|
// this item will be pruned.
|
|
245
251
|
} else {
|
|
246
|
-
|
|
252
|
+
newView.push(child);
|
|
247
253
|
}
|
|
248
254
|
}
|
|
249
255
|
}
|
|
@@ -271,10 +277,10 @@ export class Entity<
|
|
|
271
277
|
);
|
|
272
278
|
if (this.schema.type === 'map') {
|
|
273
279
|
// it's valid to prune here if it's a map
|
|
274
|
-
|
|
280
|
+
newView = {};
|
|
275
281
|
} else {
|
|
276
282
|
// otherwise prune moves upward
|
|
277
|
-
|
|
283
|
+
newView = null;
|
|
278
284
|
}
|
|
279
285
|
break;
|
|
280
286
|
}
|
|
@@ -287,28 +293,29 @@ export class Entity<
|
|
|
287
293
|
'key:',
|
|
288
294
|
key,
|
|
289
295
|
);
|
|
290
|
-
/**
|
|
291
|
-
* PRUNE - this is a prune point. we can't continue
|
|
292
|
-
* to render this data. If this is a map, we can ignore
|
|
293
|
-
* this value. Otherwise we must prune upward.
|
|
294
|
-
* This exits the loop.
|
|
295
|
-
*/
|
|
296
296
|
if (this.schema.type !== 'map') {
|
|
297
|
-
|
|
297
|
+
/**
|
|
298
|
+
* PRUNE - this is a prune point. we can't continue
|
|
299
|
+
* to render this data. If this is a map, we can ignore
|
|
300
|
+
* this value. Otherwise we must prune upward.
|
|
301
|
+
* This exits the loop.
|
|
302
|
+
*/
|
|
303
|
+
newView = null;
|
|
298
304
|
break;
|
|
299
305
|
}
|
|
300
306
|
} else {
|
|
301
307
|
// special case - rewrite undefined fields to null
|
|
302
308
|
if (isNullable(schema) && child === undefined) {
|
|
303
|
-
|
|
309
|
+
newView[key] = null;
|
|
304
310
|
} else {
|
|
305
|
-
|
|
311
|
+
newView[key] = child;
|
|
306
312
|
}
|
|
307
313
|
}
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
316
|
|
|
311
|
-
|
|
317
|
+
this.cachedView = newView;
|
|
318
|
+
return newView;
|
|
312
319
|
}
|
|
313
320
|
|
|
314
321
|
private childIsNull = (child: any) => {
|
|
@@ -597,19 +604,16 @@ export class Entity<
|
|
|
597
604
|
}
|
|
598
605
|
};
|
|
599
606
|
|
|
600
|
-
private
|
|
607
|
+
private getPruneDiff = () => {
|
|
601
608
|
const snapshot = this.getSnapshot();
|
|
602
609
|
const unprunedSnapshot = this.getUnprunedSnapshot();
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
this.applyPendingOperations(operations);
|
|
610
|
+
return this.patchCreator.createDiff(unprunedSnapshot, snapshot, {
|
|
611
|
+
authz: this.access,
|
|
612
|
+
merge: false,
|
|
613
|
+
});
|
|
614
|
+
};
|
|
615
|
+
private canonizePrunedVersion = () => {
|
|
616
|
+
this.applyPendingOperations(this.getPruneDiff());
|
|
613
617
|
};
|
|
614
618
|
|
|
615
619
|
private addConfirmedData = (data: EntityStoreEventData) => {
|
|
@@ -700,6 +704,10 @@ export class Entity<
|
|
|
700
704
|
this.emit('changeDeep', target, ev);
|
|
701
705
|
this.parent?.deepChange(target, ev);
|
|
702
706
|
};
|
|
707
|
+
[CHILD_FILE_CHANGED] = (file: EntityFile) => {
|
|
708
|
+
// consistent with prior behavior, but kind of arbitrary.
|
|
709
|
+
this.deepChange(this, { isLocal: false, oid: this.oid });
|
|
710
|
+
};
|
|
703
711
|
|
|
704
712
|
private getChild = (key: any, oid: ObjectIdentifier) => {
|
|
705
713
|
const schema = getChildFieldSchema(this.schema, key);
|
|
@@ -767,16 +775,44 @@ export class Entity<
|
|
|
767
775
|
const file = this.files.get(child.id, {
|
|
768
776
|
downloadRemote: !!fieldSchema.downloadRemote,
|
|
769
777
|
ctx: this.ctx,
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
// FIXME: this seems bad and inconsistent
|
|
773
|
-
file.subscribe('change', () => {
|
|
774
|
-
this.deepChange(this, { isLocal: false, oid: this.oid });
|
|
778
|
+
parent: this,
|
|
775
779
|
});
|
|
776
780
|
|
|
777
781
|
return file as KeyValue[Key];
|
|
778
782
|
} else {
|
|
779
|
-
|
|
783
|
+
const childEntity = this.getChild(key, child.id);
|
|
784
|
+
if (childEntity.deepInvalid) {
|
|
785
|
+
// this child is pruned. materialize a pruned version of
|
|
786
|
+
// this subtree if possible.
|
|
787
|
+
|
|
788
|
+
// special case: lists -- as long as the list itself
|
|
789
|
+
// is present and valid, it can omit invalid children
|
|
790
|
+
// selectively rather than fallback to an empty default
|
|
791
|
+
if (fieldSchema.type === 'array') {
|
|
792
|
+
return childEntity as KeyValue[Key];
|
|
793
|
+
}
|
|
794
|
+
// special case: maps -- similar to lists
|
|
795
|
+
if (fieldSchema.type === 'map') {
|
|
796
|
+
return childEntity as KeyValue[Key];
|
|
797
|
+
}
|
|
798
|
+
if (isNullable(fieldSchema)) {
|
|
799
|
+
return null as KeyValue[Key];
|
|
800
|
+
}
|
|
801
|
+
if (hasDefault(fieldSchema)) {
|
|
802
|
+
const unprunedSnapshot = childEntity.getUnprunedSnapshot();
|
|
803
|
+
const pruneDiff = this.patchCreator.createDiff(
|
|
804
|
+
unprunedSnapshot,
|
|
805
|
+
getFieldDefault(fieldSchema),
|
|
806
|
+
{
|
|
807
|
+
merge: false,
|
|
808
|
+
mergeUnknownObjects: true,
|
|
809
|
+
authz: this.access,
|
|
810
|
+
},
|
|
811
|
+
);
|
|
812
|
+
return this.processPrunedChild(key, childEntity, pruneDiff);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return childEntity as KeyValue[Key];
|
|
780
816
|
}
|
|
781
817
|
} else {
|
|
782
818
|
// if this is a Map type, a missing child is
|
|
@@ -794,16 +830,59 @@ export class Entity<
|
|
|
794
830
|
requireDefaults: true,
|
|
795
831
|
})
|
|
796
832
|
) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
833
|
+
if (hasDefault(fieldSchema)) {
|
|
834
|
+
// primitive fields with defaults are easy.
|
|
835
|
+
if (isPrimitive(fieldSchema)) {
|
|
836
|
+
return getFieldDefault(fieldSchema) as KeyValue[Key];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// object/list fields are hard.
|
|
840
|
+
const defaultValue = getFieldDefault(fieldSchema);
|
|
841
|
+
const prunedFieldOid = createSubOid(this.oid);
|
|
842
|
+
const pruneDiff = this.patchCreator.createInitialize(
|
|
843
|
+
defaultValue,
|
|
844
|
+
prunedFieldOid,
|
|
845
|
+
this.access,
|
|
846
|
+
);
|
|
847
|
+
pruneDiff.push(
|
|
848
|
+
...this.patchCreator.createSet(
|
|
849
|
+
this.oid,
|
|
850
|
+
key,
|
|
851
|
+
createRef(prunedFieldOid),
|
|
852
|
+
this.access,
|
|
853
|
+
),
|
|
854
|
+
);
|
|
855
|
+
const childEntity = this.getChild(key, prunedFieldOid);
|
|
856
|
+
return this.processPrunedChild(key, childEntity, pruneDiff);
|
|
857
|
+
} else {
|
|
858
|
+
// failure / hard prune: no way to represent this
|
|
859
|
+
// data in a valid way exists. the parent entity
|
|
860
|
+
// is also invalid and this should bubble up.
|
|
861
|
+
return undefined as KeyValue[Key];
|
|
862
|
+
}
|
|
802
863
|
}
|
|
803
864
|
return child as KeyValue[Key];
|
|
804
865
|
}
|
|
805
866
|
};
|
|
806
867
|
|
|
868
|
+
private processPrunedChild = (
|
|
869
|
+
key: any,
|
|
870
|
+
child: Entity,
|
|
871
|
+
pruneDiff: Operation[],
|
|
872
|
+
): any => {
|
|
873
|
+
this.ctx.log(
|
|
874
|
+
'warn',
|
|
875
|
+
'Replacing invalid child object field with ephemeral, valid data',
|
|
876
|
+
this.oid,
|
|
877
|
+
key,
|
|
878
|
+
);
|
|
879
|
+
const changes = this.metadataFamily.addEphemeralData(pruneDiff);
|
|
880
|
+
for (const change of changes) {
|
|
881
|
+
this.change(change);
|
|
882
|
+
}
|
|
883
|
+
return child as any;
|
|
884
|
+
};
|
|
885
|
+
|
|
807
886
|
/**
|
|
808
887
|
* Gets a value on this entity. If the value is not
|
|
809
888
|
* present, it will be set to the provided default
|
|
@@ -865,7 +944,7 @@ export class Entity<
|
|
|
865
944
|
});
|
|
866
945
|
}
|
|
867
946
|
}
|
|
868
|
-
return processValueFiles(value, this.files.add);
|
|
947
|
+
return processValueFiles(value, (file) => this.files.add(file, this));
|
|
869
948
|
};
|
|
870
949
|
|
|
871
950
|
private getDeleteMode = (key: any) => {
|