@githolon/dsl 0.117.1 → 0.119.9
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/assets/operation-contract.wasm +0 -0
- package/compile_proof.mjs +16 -0
- package/dart/CHANGELOG.md +41 -0
- package/dart/lib/src/application_status.dart +58 -2
- package/dart/lib/src/peer_application_streams.dart +7 -1
- package/dart/lib/src/reception/dart_reception.dart +92 -12
- package/dart/lib/src/reception/reception_session.dart +10 -2
- package/dart/lib/src/replica_readiness.dart +200 -50
- package/dart/lib/src/runner/node-entry.mjs +2480 -1021
- package/dart/lib/src/sync_status_projection.dart +346 -4
- package/dart/lib/src/sync_status_reducer.dart +44 -0
- package/dart/lib/src/test_harness.dart +1 -0
- package/dart/lib/src/typed_watch_registry.dart +67 -7
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +2 -2
- package/dart/test/copy_repair_status_test.dart +238 -0
- package/dart/test/reads_never_wait_test.dart +387 -0
- package/dart/test/replica_readiness_test.dart +223 -109
- package/dart/test/sync_status_reducer_test.dart +39 -0
- package/dart/test/typed_watch_registry_test.dart +69 -0
- package/dart/test/unshared_work_status_test.dart +269 -0
- package/dart_types/CHANGELOG.md +18 -0
- package/dart_types/lib/src/proto/nomos/kernel/v1/kernel.pb.dart +320 -85
- package/dart_types/lib/src/proto/nomos/runtime/v2/correspondence_desk.pb.dart +57 -1
- package/dart_types/lib/src/proto/nomos/runtime/v2/maintenance_desk.pb.dart +14 -0
- package/dart_types/lib/src/proto/nomos/runtime/v2/observability_desk.pbenum.dart +25 -1
- package/dart_types/lib/src/runtime_outcome.dart +17 -0
- package/dart_types/pubspec.yaml +1 -1
- package/package.json +5 -2
- package/src/codegen_ts.ts +7 -8
- package/src/compile_package_main.ts +11 -4
- package/src/reception_unit_artifact.ts +105 -0
- package/src/reception_unit_artifact_contract.ts +220 -0
- package/src/reception_unit_context.ts +47 -0
- package/src/reception_unit_source.ts +205 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// A damaged local copy is "repairing" on its status, never "in sync".
|
|
2
|
+
//
|
|
3
|
+
// The device that set it: Jack's iPad mini (TestFlight 1338, 11 Oct 2026 14:28 UTC). The Lippold
|
|
4
|
+
// estate's rounds "completed" (pullBytes=19641276 pulled=false mainAdvanced=false), while a separate
|
|
5
|
+
// round failed `nomos.runtime.component_failure … commit b156478a… unreadable` and reads answered
|
|
6
|
+
// localReplicaNotReady "local Git history is incomplete … being refetched". The status said every
|
|
7
|
+
// workspace was in sync, and every edit failed.
|
|
8
|
+
import 'dart:async';
|
|
9
|
+
import 'dart:typed_data';
|
|
10
|
+
|
|
11
|
+
import 'package:nomos_client/src/nms_generated_runtime.dart';
|
|
12
|
+
import 'package:nomos_client/src/replica_readiness.dart';
|
|
13
|
+
import 'package:nomos_client/src/sync_status_reducer.dart';
|
|
14
|
+
import 'package:nomos_types/nomos_kernel_protocol.dart' as p;
|
|
15
|
+
import 'package:nomos_types/src/proto/google/protobuf/any.pb.dart' as protobuf;
|
|
16
|
+
import 'package:test/test.dart';
|
|
17
|
+
|
|
18
|
+
const _workspace = 'estate-0fbc5c6a931205ae404d839df6ac9ff550b3ec82';
|
|
19
|
+
const _missing = 'b156478a9512b1aa12e93ef513e0e3693c26d6db';
|
|
20
|
+
const _resultType =
|
|
21
|
+
'type.nomos.dev/nomos.operation.v1.QueryResult_3407d8ac4c444f26';
|
|
22
|
+
const _damagedDetail =
|
|
23
|
+
"the local copy of '$_workspace' cannot be read: local Git history is incomplete; "
|
|
24
|
+
'it is being refetched from its provider';
|
|
25
|
+
|
|
26
|
+
final _query = NmsInternalGeneratedQueries.create(
|
|
27
|
+
domain: 'estate',
|
|
28
|
+
query: 'sitesByEstate',
|
|
29
|
+
protobufType: 'type.nomos.dev/nomos.operation.v1.Query_3407d8ac4c444f26',
|
|
30
|
+
protobufPayload: Uint8List.fromList([10, 1, 120]),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
final class _Office
|
|
34
|
+
with NomosRuntimeV2KernelCalls
|
|
35
|
+
implements PeerOfficeConnection {
|
|
36
|
+
bool damaged = true;
|
|
37
|
+
final events = StreamController<p.RuntimeEvent>.broadcast(sync: true);
|
|
38
|
+
int sequence = 0;
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
Stream<p.RuntimeEvent> get runtimeEvents => events.stream;
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
int nextRuntimeRequestId() => ++sequence;
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
Future<p.RuntimeResponse> invokeRuntime(p.RuntimeRequest request) async {
|
|
48
|
+
if (request.hasKernelCall() && request.kernelCall.request.hasQuery()) {
|
|
49
|
+
if (damaged) {
|
|
50
|
+
return p.RuntimeResponse(
|
|
51
|
+
protocolVersion: 2,
|
|
52
|
+
requestId: request.requestId,
|
|
53
|
+
unavailable: p.RuntimeUnavailable(
|
|
54
|
+
reason: p.RuntimeUnavailableReason
|
|
55
|
+
.RUNTIME_UNAVAILABLE_REASON_LOCAL_REPLICA_NOT_READY,
|
|
56
|
+
workspace: _workspace,
|
|
57
|
+
retryable: true,
|
|
58
|
+
retryAfterMillis: 1000,
|
|
59
|
+
detail: _damagedDetail,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return p.RuntimeResponse(
|
|
64
|
+
protocolVersion: 2,
|
|
65
|
+
requestId: request.requestId,
|
|
66
|
+
kernelCall: p.CallResponse(
|
|
67
|
+
query: p.QueryResponse(
|
|
68
|
+
applicationResult: protobuf.Any(typeUrl: _resultType, value: [7]),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (request.hasCloseRealm()) {
|
|
74
|
+
return p.RuntimeResponse(
|
|
75
|
+
protocolVersion: 2,
|
|
76
|
+
requestId: request.requestId,
|
|
77
|
+
realmClosed: p.RealmClosed(),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
fail('Unexpected command: ${request.whichCommand()}');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// The estate's round "completes": nothing pulled, head unchanged.
|
|
84
|
+
void roundCompletes() => events.add(
|
|
85
|
+
p.RuntimeEvent(
|
|
86
|
+
session: _workspace,
|
|
87
|
+
syncCompleted: p.SyncCompleted(
|
|
88
|
+
result: p.SyncWorkspaceResponse(
|
|
89
|
+
workspace: _workspace,
|
|
90
|
+
currentHead: '236fa9084f68a4baa0d00db5f8ae9587ca0d5d61',
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/// The separate failure, application-scoped as on the iPad.
|
|
97
|
+
void kernelCannotReadCommit() => events.add(
|
|
98
|
+
p.RuntimeEvent(
|
|
99
|
+
syncCompleted: p.SyncCompleted(
|
|
100
|
+
failure: p.RuntimeFailure(
|
|
101
|
+
code: 'nomos.runtime.component_failure',
|
|
102
|
+
message:
|
|
103
|
+
'workspace-sync-12 workspace=$_workspace: NMS-KERNEL-CALL: commit $_missing '
|
|
104
|
+
'unreadable: An object with id $_missing could not be found',
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
void main() {
|
|
112
|
+
late _Office office;
|
|
113
|
+
late DartReception reception;
|
|
114
|
+
late NomosSession session;
|
|
115
|
+
|
|
116
|
+
setUp(() {
|
|
117
|
+
office = _Office();
|
|
118
|
+
reception = DartReception(office);
|
|
119
|
+
session = reception.session(_workspace);
|
|
120
|
+
});
|
|
121
|
+
tearDown(() async {
|
|
122
|
+
await reception.dispose();
|
|
123
|
+
await office.events.close();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test(
|
|
127
|
+
'the iPad sequence: a completed round, a commit the kernel cannot read and an incomplete-history '
|
|
128
|
+
'read leave the estate "repairing", and a later completed round does not undo it',
|
|
129
|
+
() async {
|
|
130
|
+
office.roundCompletes();
|
|
131
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
132
|
+
|
|
133
|
+
office.kernelCannotReadCommit();
|
|
134
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.repairing);
|
|
135
|
+
expect(reception.currentStatus.repairingCopies.keys, [_workspace]);
|
|
136
|
+
expect(session.currentStatus.repairingCopies.keys, [_workspace]);
|
|
137
|
+
|
|
138
|
+
await session.nmsQuery(_query);
|
|
139
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.repairing);
|
|
140
|
+
|
|
141
|
+
// The next round "completes" over the same damaged copy: still repairing.
|
|
142
|
+
office.roundCompletes();
|
|
143
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.repairing);
|
|
144
|
+
expect(reception.currentStatus.repairingCopies.keys, [_workspace]);
|
|
145
|
+
|
|
146
|
+
// Repaired: a read answers from the copy again.
|
|
147
|
+
office.damaged = false;
|
|
148
|
+
await session.nmsQuery(_query);
|
|
149
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
150
|
+
expect(reception.currentStatus.repairingCopies, isEmpty);
|
|
151
|
+
expect(session.currentStatus.repairingCopies, isEmpty);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
test('an incomplete-history read alone marks the copy repairing', () async {
|
|
156
|
+
await session.nmsQuery(_query);
|
|
157
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.repairing);
|
|
158
|
+
expect(reception.currentStatus.repairingCopies, contains(_workspace));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a refetch that replaced the copy says it is repaired', () async {
|
|
162
|
+
office.kernelCannotReadCommit();
|
|
163
|
+
expect(reception.currentStatus.repairingCopies, contains(_workspace));
|
|
164
|
+
office.events.add(
|
|
165
|
+
p.RuntimeEvent(
|
|
166
|
+
session: _workspace,
|
|
167
|
+
syncCompleted: p.SyncCompleted(
|
|
168
|
+
result: p.SyncWorkspaceResponse(
|
|
169
|
+
workspace: _workspace,
|
|
170
|
+
currentHead: 'a' * 40,
|
|
171
|
+
custodyReplaced: true,
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
expect(reception.currentStatus.repairingCopies, isEmpty);
|
|
177
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
group('classification', () {
|
|
181
|
+
test('localReplicaNotReady with a damaged-copy reason is repairing', () {
|
|
182
|
+
expect(
|
|
183
|
+
nmsLocalCopyReadiness(
|
|
184
|
+
const NomosUnavailable(
|
|
185
|
+
reason: NomosUnavailableReason.localReplicaNotReady,
|
|
186
|
+
workspace: _workspace,
|
|
187
|
+
retryable: true,
|
|
188
|
+
detail: _damagedDetail,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
LocalCopyReadiness.repairing,
|
|
192
|
+
);
|
|
193
|
+
expect(
|
|
194
|
+
nmsLocalCopyReadiness(
|
|
195
|
+
const NomosUnavailable(
|
|
196
|
+
reason: NomosUnavailableReason.localReplicaNotReady,
|
|
197
|
+
workspace: _workspace,
|
|
198
|
+
retryable: true,
|
|
199
|
+
detail: 'opening the workspace',
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
LocalCopyReadiness.arriving,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a round over a copy being refetched names the workspace', () {
|
|
207
|
+
expect(
|
|
208
|
+
nmsDamagedCopyOf(
|
|
209
|
+
p.SyncCompleted(
|
|
210
|
+
result: p.SyncWorkspaceResponse(
|
|
211
|
+
workspace: _workspace,
|
|
212
|
+
reconnectRequired: true,
|
|
213
|
+
discoveryError: p.RuntimeTransportFailure(
|
|
214
|
+
message: 'nomos.runtime.local_copy_refetching: unreadable',
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
_workspace,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('an ordinary failure is not a damaged copy', () {
|
|
224
|
+
expect(
|
|
225
|
+
nmsDamagedCopyOf(
|
|
226
|
+
p.SyncCompleted(
|
|
227
|
+
failure: p.RuntimeFailure(
|
|
228
|
+
code: 'nomos.stream.timeout',
|
|
229
|
+
message: "'$_workspace' stream did not answer in time",
|
|
230
|
+
workspace: _workspace,
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
isNull,
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Jack's rule (11 Oct 2026): the UI never waits on the network. Every read answers at once from
|
|
2
|
+
// what is on this device; sync is a background effect.
|
|
3
|
+
//
|
|
4
|
+
// The device that set it: TestFlight 1334, 11 Oct 2026 10:07 UTC. In the Lippold catalogue panel a
|
|
5
|
+
// read of data-profile-c6155961 (first fetch, previousHead=none) was held 11 s, until its download
|
|
6
|
+
// finished, behind a full-panel spinner.
|
|
7
|
+
import 'dart:async';
|
|
8
|
+
import 'dart:typed_data';
|
|
9
|
+
|
|
10
|
+
import 'package:nomos_client/src/application_status.dart';
|
|
11
|
+
import 'package:nomos_client/src/nms_generated_runtime.dart';
|
|
12
|
+
import 'package:nomos_types/nomos_kernel_protocol.dart' as p;
|
|
13
|
+
import 'package:nomos_types/src/proto/google/protobuf/any.pb.dart' as protobuf;
|
|
14
|
+
import 'package:test/test.dart';
|
|
15
|
+
|
|
16
|
+
const _workspace = 'data-profile-c6155961aa5f0d41b7958269c5b9e957fe90b74d';
|
|
17
|
+
const _resultType =
|
|
18
|
+
'type.nomos.dev/nomos.operation.v1.QueryResult_3407d8ac4c444f26';
|
|
19
|
+
|
|
20
|
+
final _query = NmsInternalGeneratedQueries.create(
|
|
21
|
+
domain: 'data_profile',
|
|
22
|
+
query: 'fieldsByProfileId',
|
|
23
|
+
protobufType: 'type.nomos.dev/nomos.operation.v1.Query_3407d8ac4c444f26',
|
|
24
|
+
protobufPayload: Uint8List.fromList([10, 1, 120]),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/// The Peer Office as this device meets it: [copy] says what it holds of the workspace.
|
|
28
|
+
enum _Copy {
|
|
29
|
+
absent,
|
|
30
|
+
|
|
31
|
+
/// Never on this device: the office answers FIRST_DOWNLOAD (Jack, 11 Oct 2026).
|
|
32
|
+
firstDownload,
|
|
33
|
+
refreshingUnderNewLaw,
|
|
34
|
+
servedFromLastReady,
|
|
35
|
+
here,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
final class _Office
|
|
39
|
+
with NomosRuntimeV2KernelCalls
|
|
40
|
+
implements PeerOfficeConnection {
|
|
41
|
+
_Copy copy = _Copy.absent;
|
|
42
|
+
final events = StreamController<p.RuntimeEvent>.broadcast(sync: true);
|
|
43
|
+
final calls = <p.RuntimeRequest>[];
|
|
44
|
+
int sequence = 0;
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
Stream<p.RuntimeEvent> get runtimeEvents => events.stream;
|
|
48
|
+
|
|
49
|
+
@override
|
|
50
|
+
int nextRuntimeRequestId() => ++sequence;
|
|
51
|
+
|
|
52
|
+
p.RuntimeResponse _unavailable(p.RuntimeRequest request) => p.RuntimeResponse(
|
|
53
|
+
protocolVersion: 2,
|
|
54
|
+
requestId: request.requestId,
|
|
55
|
+
unavailable: p.RuntimeUnavailable(
|
|
56
|
+
reason: switch (copy) {
|
|
57
|
+
_Copy.refreshingUnderNewLaw => p.RuntimeUnavailableReason
|
|
58
|
+
.RUNTIME_UNAVAILABLE_REASON_READ_INDEX_REFRESHING_SUCCESSOR_LAW,
|
|
59
|
+
_Copy.firstDownload => p.RuntimeUnavailableReason
|
|
60
|
+
.RUNTIME_UNAVAILABLE_REASON_FIRST_DOWNLOAD,
|
|
61
|
+
_ => p.RuntimeUnavailableReason
|
|
62
|
+
.RUNTIME_UNAVAILABLE_REASON_LOCAL_REPLICA_NOT_READY,
|
|
63
|
+
},
|
|
64
|
+
workspace: _workspace,
|
|
65
|
+
retryable: true,
|
|
66
|
+
// Long on purpose: only the workspace's sync may end a watch's pause.
|
|
67
|
+
retryAfterMillis: 60000,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
@override
|
|
72
|
+
Future<p.RuntimeResponse> invokeRuntime(p.RuntimeRequest request) async {
|
|
73
|
+
calls.add(request.deepCopy());
|
|
74
|
+
final response = p.RuntimeResponse(
|
|
75
|
+
protocolVersion: 2,
|
|
76
|
+
requestId: request.requestId,
|
|
77
|
+
);
|
|
78
|
+
if (request.hasKernelCall() && request.kernelCall.request.hasOffer()) {
|
|
79
|
+
// A write is judged only against a copy that is here (and ready for editing).
|
|
80
|
+
if (copy != _Copy.here) return _unavailable(request);
|
|
81
|
+
response.kernelCall = p.CallResponse(
|
|
82
|
+
offer: p.OfferResponse(
|
|
83
|
+
admitted: p.OfferAdmitted(head: 'a' * 40, intentId: 'intent-1'),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
} else if (request.hasKernelCall() &&
|
|
87
|
+
request.kernelCall.request.hasQuery()) {
|
|
88
|
+
if (copy == _Copy.absent ||
|
|
89
|
+
copy == _Copy.firstDownload ||
|
|
90
|
+
copy == _Copy.refreshingUnderNewLaw) {
|
|
91
|
+
return _unavailable(request);
|
|
92
|
+
}
|
|
93
|
+
final query = p.QueryResponse(
|
|
94
|
+
applicationResult: protobuf.Any(typeUrl: _resultType, value: [7, 8]),
|
|
95
|
+
);
|
|
96
|
+
if (copy == _Copy.servedFromLastReady) {
|
|
97
|
+
// CAP-891/900: answered from the last-ready index, the head's index owed in the
|
|
98
|
+
// background.
|
|
99
|
+
query.readIndexBehind = p.ReadIndexBehind(
|
|
100
|
+
workspace: _workspace,
|
|
101
|
+
servedFrontier: 'c966',
|
|
102
|
+
requestedFrontier: 'f7b7',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
response.kernelCall = p.CallResponse(query: query);
|
|
106
|
+
} else if (request.hasWatch()) {
|
|
107
|
+
if (copy == _Copy.absent ||
|
|
108
|
+
copy == _Copy.firstDownload ||
|
|
109
|
+
copy == _Copy.refreshingUnderNewLaw) {
|
|
110
|
+
return _unavailable(request);
|
|
111
|
+
}
|
|
112
|
+
response.watchStarted = p.WatchStarted(
|
|
113
|
+
subscriptionId: request.watch.subscriptionId,
|
|
114
|
+
);
|
|
115
|
+
} else if (request.hasUnwatch()) {
|
|
116
|
+
response.watchStopped = p.WatchStopped(
|
|
117
|
+
subscriptionId: request.unwatch.subscriptionId,
|
|
118
|
+
);
|
|
119
|
+
} else if (request.hasCloseRealm()) {
|
|
120
|
+
response.realmClosed = p.RealmClosed();
|
|
121
|
+
} else {
|
|
122
|
+
fail('Unexpected command: ${request.whichCommand()}');
|
|
123
|
+
}
|
|
124
|
+
return response;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// The copy's download landed: the office reports the sync round.
|
|
128
|
+
void synced() => events.add(
|
|
129
|
+
p.RuntimeEvent(
|
|
130
|
+
syncCompleted: p.SyncCompleted(
|
|
131
|
+
result: p.SyncWorkspaceResponse(workspace: _workspace),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
void emit(p.WatchChanged changed) {
|
|
137
|
+
changed.subscriptionId =
|
|
138
|
+
calls.lastWhere((r) => r.hasWatch()).watch.subscriptionId;
|
|
139
|
+
events.add(p.RuntimeEvent(watchChanged: changed));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
int get queries => calls
|
|
143
|
+
.where((r) => r.hasKernelCall() && r.kernelCall.request.hasQuery())
|
|
144
|
+
.length;
|
|
145
|
+
int get watches => calls.where((r) => r.hasWatch()).length;
|
|
146
|
+
int get offers => calls
|
|
147
|
+
.where((r) => r.hasKernelCall() && r.kernelCall.request.hasOffer())
|
|
148
|
+
.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
List<int> _rows(NmsGeneratedResult result) {
|
|
152
|
+
expect(result.protobufType, _resultType);
|
|
153
|
+
return result.protobufPayload.toList();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
void main() {
|
|
157
|
+
late _Office office;
|
|
158
|
+
late DartReception reception;
|
|
159
|
+
late NomosSession session;
|
|
160
|
+
|
|
161
|
+
setUp(() {
|
|
162
|
+
office = _Office();
|
|
163
|
+
reception = DartReception(office);
|
|
164
|
+
session = reception.session(_workspace);
|
|
165
|
+
});
|
|
166
|
+
tearDown(() async {
|
|
167
|
+
await reception.dispose();
|
|
168
|
+
await office.events.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test(
|
|
172
|
+
'a read of a never-fetched workspace answers empty and "arriving" at once, asked once',
|
|
173
|
+
() async {
|
|
174
|
+
final clock = Stopwatch()..start();
|
|
175
|
+
final result = await session.nmsQuery(_query);
|
|
176
|
+
clock.stop();
|
|
177
|
+
expect(
|
|
178
|
+
clock.elapsedMilliseconds,
|
|
179
|
+
lessThan(50),
|
|
180
|
+
reason: 'no wait, no retry',
|
|
181
|
+
);
|
|
182
|
+
expect(office.queries, 1);
|
|
183
|
+
expect(
|
|
184
|
+
_rows(result),
|
|
185
|
+
isEmpty,
|
|
186
|
+
reason: 'the generated result type, with no rows',
|
|
187
|
+
);
|
|
188
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.arriving);
|
|
189
|
+
|
|
190
|
+
// The download lands in the background; the next read answers from the copy.
|
|
191
|
+
office.copy = _Copy.here;
|
|
192
|
+
office.synced();
|
|
193
|
+
expect(_rows(await session.nmsQuery(_query)), [7, 8]);
|
|
194
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
test(
|
|
199
|
+
'a page, a by-id read and a count answer their empty forms at once',
|
|
200
|
+
() async {
|
|
201
|
+
final clock = Stopwatch()..start();
|
|
202
|
+
final page = await session.nmsQueryPage(_query);
|
|
203
|
+
final byId = await session.nmsQueryByIdUsda('catalogue.Catalogue', 'c1');
|
|
204
|
+
final count = await session.nmsCount('listingCount');
|
|
205
|
+
clock.stop();
|
|
206
|
+
expect(clock.elapsedMilliseconds, lessThan(50));
|
|
207
|
+
expect(page.protobufType, _resultType);
|
|
208
|
+
expect(page.protobufPayload, isEmpty);
|
|
209
|
+
expect(NmsUsdaResult.parse(byId).rows, isEmpty);
|
|
210
|
+
expect(count, 0);
|
|
211
|
+
expect(office.queries, 3);
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
test(
|
|
216
|
+
'a copy re-indexed under a law this app cannot decode answers empty and "updating" at once',
|
|
217
|
+
() async {
|
|
218
|
+
office.copy = _Copy.refreshingUnderNewLaw;
|
|
219
|
+
final clock = Stopwatch()..start();
|
|
220
|
+
final result = await session.nmsQuery(_query);
|
|
221
|
+
clock.stop();
|
|
222
|
+
expect(clock.elapsedMilliseconds, lessThan(50));
|
|
223
|
+
expect(_rows(result), isEmpty);
|
|
224
|
+
expect(office.queries, 1);
|
|
225
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.updating);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
test(
|
|
230
|
+
'a copy being re-indexed answers from its last-ready frontier, ready',
|
|
231
|
+
() async {
|
|
232
|
+
office.copy = _Copy.servedFromLastReady;
|
|
233
|
+
final clock = Stopwatch()..start();
|
|
234
|
+
final result = await session.nmsQuery(_query);
|
|
235
|
+
clock.stop();
|
|
236
|
+
expect(clock.elapsedMilliseconds, lessThan(50));
|
|
237
|
+
expect(_rows(result), [7, 8]);
|
|
238
|
+
expect(office.queries, 1);
|
|
239
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
test(
|
|
244
|
+
'a watch emits empty and "arriving" at once, then the rows after the sync lands',
|
|
245
|
+
() async {
|
|
246
|
+
final statuses = <LocalCopyReadiness>[];
|
|
247
|
+
final statusSubscription = session.syncStatus
|
|
248
|
+
.map((status) => status.localCopy)
|
|
249
|
+
.distinct()
|
|
250
|
+
.listen(statuses.add);
|
|
251
|
+
final emissions = StreamIterator(
|
|
252
|
+
session.nmsWatchRows<int>(_query, decode: _rows, idOf: (row) => '$row'),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
final clock = Stopwatch()..start();
|
|
256
|
+
expect(await emissions.moveNext(), isTrue);
|
|
257
|
+
clock.stop();
|
|
258
|
+
expect(clock.elapsedMilliseconds, lessThan(50));
|
|
259
|
+
expect(
|
|
260
|
+
emissions.current,
|
|
261
|
+
isEmpty,
|
|
262
|
+
reason: 'what is on the device: nothing',
|
|
263
|
+
);
|
|
264
|
+
expect(office.watches, 1);
|
|
265
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.arriving);
|
|
266
|
+
|
|
267
|
+
// The download lands: the watch registers the moment the sync completes, not after its
|
|
268
|
+
// runtime-suggested minute.
|
|
269
|
+
office.copy = _Copy.here;
|
|
270
|
+
final next = emissions.moveNext();
|
|
271
|
+
office.synced();
|
|
272
|
+
await pumpEventQueue();
|
|
273
|
+
expect(office.watches, 2);
|
|
274
|
+
office.emit(
|
|
275
|
+
p.WatchChanged(
|
|
276
|
+
applicationResult: protobuf.Any(typeUrl: _resultType, value: [7, 8]),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
expect(await next.timeout(const Duration(seconds: 1)), isTrue);
|
|
280
|
+
expect(emissions.current, [7, 8]);
|
|
281
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
282
|
+
await pumpEventQueue();
|
|
283
|
+
expect(statuses, [
|
|
284
|
+
LocalCopyReadiness.ready, // the session's status before any read
|
|
285
|
+
LocalCopyReadiness.arriving,
|
|
286
|
+
LocalCopyReadiness.ready,
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
await emissions.cancel();
|
|
290
|
+
await statusSubscription.cancel();
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
test(
|
|
295
|
+
'a watch on a copy that is here registers once and shows its rows',
|
|
296
|
+
() async {
|
|
297
|
+
office.copy = _Copy.here;
|
|
298
|
+
final emissions = StreamIterator(session.nmsWatch(_query));
|
|
299
|
+
final first = emissions.moveNext();
|
|
300
|
+
office.emit(
|
|
301
|
+
p.WatchChanged(
|
|
302
|
+
applicationResult: protobuf.Any(typeUrl: _resultType, value: [7]),
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
expect(await first, isTrue);
|
|
306
|
+
expect(_rows(emissions.current), [7], reason: 'no empty emission first');
|
|
307
|
+
expect(office.watches, 1);
|
|
308
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
309
|
+
await emissions.cancel();
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
test('cancelling a watch still waiting for its copy stops at once', () async {
|
|
314
|
+
final emissions = StreamIterator(session.nmsWatch(_query));
|
|
315
|
+
expect(await emissions.moveNext(), isTrue);
|
|
316
|
+
expect(_rows(emissions.current), isEmpty);
|
|
317
|
+
await emissions.cancel().timeout(const Duration(seconds: 1));
|
|
318
|
+
expect(office.watches, 1, reason: 'no re-registration after the cancel');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
group(
|
|
322
|
+
'first download (Jack, 11 Oct 2026: one-time, explained, no awaiting-base outbox)',
|
|
323
|
+
() {
|
|
324
|
+
final write = NmsInternalGeneratedCalls.create(
|
|
325
|
+
domain: 'estate',
|
|
326
|
+
directive: 'createAsset',
|
|
327
|
+
protobufType: 'type.nomos.dev/nomos.operation.v1.Payload_create_asset',
|
|
328
|
+
protobufPayload: Uint8List.fromList([1]),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
test('a read says "first download", not merely "arriving"', () async {
|
|
332
|
+
office.copy = _Copy.firstDownload;
|
|
333
|
+
final result = await session.nmsQuery(_query);
|
|
334
|
+
expect(_rows(result), isEmpty);
|
|
335
|
+
expect(
|
|
336
|
+
session.currentStatus.localCopy,
|
|
337
|
+
LocalCopyReadiness.firstDownload,
|
|
338
|
+
);
|
|
339
|
+
final status = ApplicationStatus(
|
|
340
|
+
phase: ApplicationStatusPhase.receiving,
|
|
341
|
+
localChanges: 0,
|
|
342
|
+
availableChanges: null,
|
|
343
|
+
savedChanges: null,
|
|
344
|
+
acceptedChanges: null,
|
|
345
|
+
decisionsNeeded: null,
|
|
346
|
+
localCopy: session.currentStatus.localCopy,
|
|
347
|
+
);
|
|
348
|
+
expect(status.localCopyFirstDownload, isTrue);
|
|
349
|
+
expect(status.localCopyArriving, isTrue, reason: 'CO2-1776 guards hold');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test(
|
|
353
|
+
'a write is refused at once with the typed reason, and admits once the copy is here',
|
|
354
|
+
() async {
|
|
355
|
+
office.copy = _Copy.firstDownload;
|
|
356
|
+
final clock = Stopwatch()..start();
|
|
357
|
+
await expectLater(
|
|
358
|
+
session.offerGeneratedCallOutcome(call: write),
|
|
359
|
+
throwsA(
|
|
360
|
+
isA<NomosUnavailable>()
|
|
361
|
+
.having((e) => e.reason, 'reason',
|
|
362
|
+
NomosUnavailableReason.firstDownload)
|
|
363
|
+
.having((e) => e.retryable, 'retryable', isTrue)
|
|
364
|
+
.having((e) => e.workspace, 'workspace', _workspace),
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
clock.stop();
|
|
368
|
+
expect(clock.elapsedMilliseconds, lessThan(100));
|
|
369
|
+
expect(office.offers, 1, reason: 'asked once: no hidden retry loop');
|
|
370
|
+
|
|
371
|
+
// The copy lands (its indexes being prepared): reads say "arriving", no longer "first".
|
|
372
|
+
office.copy = _Copy.absent;
|
|
373
|
+
await session.nmsQuery(_query);
|
|
374
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.arriving);
|
|
375
|
+
|
|
376
|
+
// Ready: a read answers from the copy and the next write admits.
|
|
377
|
+
office.copy = _Copy.here;
|
|
378
|
+
office.synced();
|
|
379
|
+
await session.nmsQuery(_query);
|
|
380
|
+
expect(session.currentStatus.localCopy, LocalCopyReadiness.ready);
|
|
381
|
+
final admitted = await session.offerGeneratedCallOutcome(call: write);
|
|
382
|
+
expect(admitted.intentId, 'intent-1');
|
|
383
|
+
expect(office.offers, 2);
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
}
|