@sylphx/lens-server 2.11.0 → 2.13.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/index.js +26 -10
- package/package.json +2 -2
- package/src/server/create.test.ts +132 -0
- package/src/server/create.ts +50 -15
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ function extendContext(current, extension) {
|
|
|
35
35
|
}
|
|
36
36
|
// src/server/create.ts
|
|
37
37
|
import {
|
|
38
|
+
applyUpdate,
|
|
38
39
|
collectModelsFromOperations,
|
|
39
40
|
collectModelsFromRouter,
|
|
40
41
|
createEmit,
|
|
@@ -696,19 +697,26 @@ class LensServerImpl {
|
|
|
696
697
|
return { ...state, ...command.data };
|
|
697
698
|
}
|
|
698
699
|
return command.data;
|
|
699
|
-
case "field":
|
|
700
|
+
case "field": {
|
|
701
|
+
if (command.field === "") {
|
|
702
|
+
return applyUpdate(state, command.update);
|
|
703
|
+
}
|
|
700
704
|
if (state && typeof state === "object") {
|
|
705
|
+
const currentValue = state[command.field];
|
|
706
|
+
const newValue = applyUpdate(currentValue, command.update);
|
|
701
707
|
return {
|
|
702
708
|
...state,
|
|
703
|
-
[command.field]:
|
|
709
|
+
[command.field]: newValue
|
|
704
710
|
};
|
|
705
711
|
}
|
|
706
|
-
return { [command.field]: command.update
|
|
712
|
+
return { [command.field]: applyUpdate(undefined, command.update) };
|
|
713
|
+
}
|
|
707
714
|
case "batch":
|
|
708
715
|
if (state && typeof state === "object") {
|
|
709
716
|
const result = { ...state };
|
|
710
717
|
for (const update of command.updates) {
|
|
711
|
-
result[update.field]
|
|
718
|
+
const currentValue = result[update.field];
|
|
719
|
+
result[update.field] = applyUpdate(currentValue, update.update);
|
|
712
720
|
}
|
|
713
721
|
return result;
|
|
714
722
|
}
|
|
@@ -739,18 +747,26 @@ class LensServerImpl {
|
|
|
739
747
|
}
|
|
740
748
|
}
|
|
741
749
|
createFieldEmitFactory(getCurrentState, setCurrentState, notifyObserver, select, context, onCleanup) {
|
|
742
|
-
return (fieldPath) => {
|
|
750
|
+
return (fieldPath, resolvedValue) => {
|
|
743
751
|
if (!fieldPath)
|
|
744
752
|
return;
|
|
745
753
|
const state = getCurrentState();
|
|
746
|
-
const currentFieldValue = state ? this.getFieldByPath(state, fieldPath) : undefined;
|
|
747
|
-
|
|
754
|
+
const currentFieldValue = resolvedValue !== undefined ? resolvedValue : state ? this.getFieldByPath(state, fieldPath) : undefined;
|
|
755
|
+
let outputType = "object";
|
|
756
|
+
if (Array.isArray(currentFieldValue)) {
|
|
757
|
+
outputType = "array";
|
|
758
|
+
} else if (currentFieldValue === null || typeof currentFieldValue === "string" || typeof currentFieldValue === "number" || typeof currentFieldValue === "boolean") {
|
|
759
|
+
outputType = "scalar";
|
|
760
|
+
}
|
|
761
|
+
let localFieldValue = resolvedValue;
|
|
748
762
|
const emitHandler = (command) => {
|
|
749
763
|
const fullState = getCurrentState();
|
|
750
764
|
if (!fullState || typeof fullState !== "object")
|
|
751
765
|
return;
|
|
752
|
-
const
|
|
766
|
+
const stateFieldValue = this.getFieldByPath(fullState, fieldPath);
|
|
767
|
+
const fieldValue = stateFieldValue !== undefined ? stateFieldValue : localFieldValue;
|
|
753
768
|
const newFieldValue = this.applyEmitCommand(command, fieldValue);
|
|
769
|
+
localFieldValue = newFieldValue;
|
|
754
770
|
const updatedState = this.setFieldByPath(fullState, fieldPath, newFieldValue);
|
|
755
771
|
setCurrentState(updatedState);
|
|
756
772
|
(async () => {
|
|
@@ -764,7 +780,7 @@ class LensServerImpl {
|
|
|
764
780
|
}
|
|
765
781
|
})();
|
|
766
782
|
};
|
|
767
|
-
return createEmit(emitHandler,
|
|
783
|
+
return createEmit(emitHandler, outputType);
|
|
768
784
|
};
|
|
769
785
|
}
|
|
770
786
|
getFieldByPath(obj, path) {
|
|
@@ -845,7 +861,7 @@ class LensServerImpl {
|
|
|
845
861
|
const publisher = resolverDef.subscribeField(field, obj, args, context ?? {});
|
|
846
862
|
if (publisher && createFieldEmit && onCleanup) {
|
|
847
863
|
try {
|
|
848
|
-
const fieldEmit = createFieldEmit(currentPath);
|
|
864
|
+
const fieldEmit = createFieldEmit(currentPath, result[field]);
|
|
849
865
|
if (fieldEmit) {
|
|
850
866
|
publisher({
|
|
851
867
|
emit: fieldEmit,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Server runtime for Lens API framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"author": "SylphxAI",
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@sylphx/lens-core": "^2.
|
|
33
|
+
"@sylphx/lens-core": "^2.11.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|
|
@@ -2101,3 +2101,135 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
|
|
|
2101
2101
|
sub2.unsubscribe();
|
|
2102
2102
|
});
|
|
2103
2103
|
});
|
|
2104
|
+
|
|
2105
|
+
// =============================================================================
|
|
2106
|
+
// Scalar Field Subscription Tests (EmitScalar)
|
|
2107
|
+
// =============================================================================
|
|
2108
|
+
|
|
2109
|
+
describe("scalar field subscription with emit.delta()", () => {
|
|
2110
|
+
it("provides emit.delta() for string field subscriptions", async () => {
|
|
2111
|
+
// UserWithBio - bio field is not provided by query, resolved by field resolver
|
|
2112
|
+
const UserWithBio = entity("UserWithBio", {
|
|
2113
|
+
id: t.id(),
|
|
2114
|
+
name: t.string(),
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
let capturedEmit: {
|
|
2118
|
+
(value: unknown): void;
|
|
2119
|
+
delta?: (operations: { position: number; insert: string }[]) => void;
|
|
2120
|
+
} | null = null;
|
|
2121
|
+
|
|
2122
|
+
const userResolver = resolver(UserWithBio, (f) => ({
|
|
2123
|
+
id: f.expose("id"),
|
|
2124
|
+
name: f.expose("name"),
|
|
2125
|
+
// bio is a computed field with resolve + subscribe
|
|
2126
|
+
bio: f
|
|
2127
|
+
.string()
|
|
2128
|
+
.resolve(() => "Initial bio")
|
|
2129
|
+
.subscribe((_params) => ({ emit, onCleanup }) => {
|
|
2130
|
+
capturedEmit = emit as typeof capturedEmit;
|
|
2131
|
+
onCleanup(() => {});
|
|
2132
|
+
}),
|
|
2133
|
+
}));
|
|
2134
|
+
|
|
2135
|
+
const server = createApp({
|
|
2136
|
+
entities: { UserWithBio },
|
|
2137
|
+
queries: {
|
|
2138
|
+
getUserWithBio: query()
|
|
2139
|
+
.input(z.object({ id: z.string() }))
|
|
2140
|
+
.returns(UserWithBio)
|
|
2141
|
+
.resolve(({ input }) => ({ id: input.id, name: "Alice" })),
|
|
2142
|
+
},
|
|
2143
|
+
resolvers: [userResolver],
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
const results: unknown[] = [];
|
|
2147
|
+
const subscription = server
|
|
2148
|
+
.execute({
|
|
2149
|
+
path: "getUserWithBio",
|
|
2150
|
+
input: { id: "1" },
|
|
2151
|
+
})
|
|
2152
|
+
.subscribe({
|
|
2153
|
+
next: (result) => results.push(result),
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
// Wait for subscription setup
|
|
2157
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2158
|
+
|
|
2159
|
+
// Initial result
|
|
2160
|
+
expect(results.length).toBe(1);
|
|
2161
|
+
expect((results[0] as { data: { bio: string } }).data.bio).toBe("Initial bio");
|
|
2162
|
+
|
|
2163
|
+
// Check that emit has delta method (EmitScalar)
|
|
2164
|
+
expect(capturedEmit).toBeDefined();
|
|
2165
|
+
expect(typeof capturedEmit?.delta).toBe("function");
|
|
2166
|
+
|
|
2167
|
+
subscription.unsubscribe();
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
it("emit.delta() appends text to string field", async () => {
|
|
2171
|
+
// UserWithContent - content field resolved by field resolver
|
|
2172
|
+
const UserWithContent = entity("UserWithContent", {
|
|
2173
|
+
id: t.id(),
|
|
2174
|
+
name: t.string(),
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
let capturedEmit: {
|
|
2178
|
+
(value: unknown): void;
|
|
2179
|
+
delta?: (operations: { position: number; insert: string }[]) => void;
|
|
2180
|
+
} | null = null;
|
|
2181
|
+
|
|
2182
|
+
const userResolver = resolver(UserWithContent, (f) => ({
|
|
2183
|
+
id: f.expose("id"),
|
|
2184
|
+
name: f.expose("name"),
|
|
2185
|
+
// content is a computed field with resolve + subscribe
|
|
2186
|
+
content: f
|
|
2187
|
+
.string()
|
|
2188
|
+
.resolve(() => "Hello")
|
|
2189
|
+
.subscribe((_params) => ({ emit, onCleanup }) => {
|
|
2190
|
+
capturedEmit = emit as typeof capturedEmit;
|
|
2191
|
+
onCleanup(() => {});
|
|
2192
|
+
}),
|
|
2193
|
+
}));
|
|
2194
|
+
|
|
2195
|
+
const server = createApp({
|
|
2196
|
+
entities: { UserWithContent },
|
|
2197
|
+
queries: {
|
|
2198
|
+
getUserWithContent: query()
|
|
2199
|
+
.input(z.object({ id: z.string() }))
|
|
2200
|
+
.returns(UserWithContent)
|
|
2201
|
+
.resolve(({ input }) => ({ id: input.id, name: "Alice" })),
|
|
2202
|
+
},
|
|
2203
|
+
resolvers: [userResolver],
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
const results: unknown[] = [];
|
|
2207
|
+
const subscription = server
|
|
2208
|
+
.execute({
|
|
2209
|
+
path: "getUserWithContent",
|
|
2210
|
+
input: { id: "1" },
|
|
2211
|
+
})
|
|
2212
|
+
.subscribe({
|
|
2213
|
+
next: (result) => results.push(result),
|
|
2214
|
+
});
|
|
2215
|
+
|
|
2216
|
+
// Wait for subscription setup
|
|
2217
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2218
|
+
|
|
2219
|
+
// Initial result
|
|
2220
|
+
expect(results.length).toBe(1);
|
|
2221
|
+
expect((results[0] as { data: { content: string } }).data.content).toBe("Hello");
|
|
2222
|
+
|
|
2223
|
+
// Use emit.delta() to append text
|
|
2224
|
+
capturedEmit?.delta?.([{ position: Infinity, insert: " World" }]);
|
|
2225
|
+
|
|
2226
|
+
// Wait for update
|
|
2227
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2228
|
+
|
|
2229
|
+
// Should have received update with delta applied
|
|
2230
|
+
expect(results.length).toBe(2);
|
|
2231
|
+
expect((results[1] as { data: { content: string } }).data.content).toBe("Hello World");
|
|
2232
|
+
|
|
2233
|
+
subscription.unsubscribe();
|
|
2234
|
+
});
|
|
2235
|
+
});
|
package/src/server/create.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
|
+
applyUpdate,
|
|
19
20
|
type ContextValue,
|
|
20
21
|
collectModelsFromOperations,
|
|
21
22
|
collectModelsFromRouter,
|
|
@@ -763,20 +764,29 @@ class LensServerImpl<
|
|
|
763
764
|
}
|
|
764
765
|
return command.data;
|
|
765
766
|
|
|
766
|
-
case "field":
|
|
767
|
+
case "field": {
|
|
768
|
+
// Empty field = scalar root value (e.g., emit.delta on a string field)
|
|
769
|
+
if (command.field === "") {
|
|
770
|
+
return applyUpdate(state, command.update);
|
|
771
|
+
}
|
|
772
|
+
// Named field - apply update to that field
|
|
767
773
|
if (state && typeof state === "object") {
|
|
774
|
+
const currentValue = (state as Record<string, unknown>)[command.field];
|
|
775
|
+
const newValue = applyUpdate(currentValue, command.update);
|
|
768
776
|
return {
|
|
769
777
|
...(state as Record<string, unknown>),
|
|
770
|
-
[command.field]:
|
|
778
|
+
[command.field]: newValue,
|
|
771
779
|
};
|
|
772
780
|
}
|
|
773
|
-
return { [command.field]: command.update
|
|
781
|
+
return { [command.field]: applyUpdate(undefined, command.update) };
|
|
782
|
+
}
|
|
774
783
|
|
|
775
784
|
case "batch":
|
|
776
785
|
if (state && typeof state === "object") {
|
|
777
786
|
const result = { ...(state as Record<string, unknown>) };
|
|
778
787
|
for (const update of command.updates) {
|
|
779
|
-
result[update.field]
|
|
788
|
+
const currentValue = result[update.field];
|
|
789
|
+
result[update.field] = applyUpdate(currentValue, update.update);
|
|
780
790
|
}
|
|
781
791
|
return result;
|
|
782
792
|
}
|
|
@@ -825,24 +835,48 @@ class LensServerImpl<
|
|
|
825
835
|
select: SelectionObject | undefined,
|
|
826
836
|
context: TContext | undefined,
|
|
827
837
|
onCleanup: ((fn: () => void) => void) | undefined,
|
|
828
|
-
): (fieldPath: string) => Emit<unknown> | undefined {
|
|
829
|
-
return (fieldPath: string) => {
|
|
838
|
+
): (fieldPath: string, resolvedValue?: unknown) => Emit<unknown> | undefined {
|
|
839
|
+
return (fieldPath: string, resolvedValue?: unknown) => {
|
|
830
840
|
if (!fieldPath) return undefined;
|
|
831
841
|
|
|
832
|
-
// Determine
|
|
842
|
+
// Determine output type from resolved value (if provided) or current field value
|
|
833
843
|
const state = getCurrentState();
|
|
834
|
-
const currentFieldValue =
|
|
835
|
-
|
|
844
|
+
const currentFieldValue =
|
|
845
|
+
resolvedValue !== undefined
|
|
846
|
+
? resolvedValue
|
|
847
|
+
: state
|
|
848
|
+
? this.getFieldByPath(state, fieldPath)
|
|
849
|
+
: undefined;
|
|
850
|
+
|
|
851
|
+
// Determine emit type: array, scalar, or object
|
|
852
|
+
let outputType: "array" | "object" | "scalar" = "object";
|
|
853
|
+
if (Array.isArray(currentFieldValue)) {
|
|
854
|
+
outputType = "array";
|
|
855
|
+
} else if (
|
|
856
|
+
currentFieldValue === null ||
|
|
857
|
+
typeof currentFieldValue === "string" ||
|
|
858
|
+
typeof currentFieldValue === "number" ||
|
|
859
|
+
typeof currentFieldValue === "boolean"
|
|
860
|
+
) {
|
|
861
|
+
outputType = "scalar";
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Track field value locally (for fields not yet in fullState)
|
|
865
|
+
let localFieldValue = resolvedValue;
|
|
836
866
|
|
|
837
867
|
// Create emit handler that applies commands to the field's value
|
|
838
868
|
const emitHandler = (command: EmitCommand) => {
|
|
839
869
|
const fullState = getCurrentState();
|
|
840
870
|
if (!fullState || typeof fullState !== "object") return;
|
|
841
871
|
|
|
842
|
-
// Get current field value
|
|
843
|
-
const
|
|
872
|
+
// Get current field value from state, or use local value if not in state yet
|
|
873
|
+
const stateFieldValue = this.getFieldByPath(fullState, fieldPath);
|
|
874
|
+
const fieldValue = stateFieldValue !== undefined ? stateFieldValue : localFieldValue;
|
|
844
875
|
const newFieldValue = this.applyEmitCommand(command, fieldValue);
|
|
845
876
|
|
|
877
|
+
// Update local tracking
|
|
878
|
+
localFieldValue = newFieldValue;
|
|
879
|
+
|
|
846
880
|
// Update state with new field value
|
|
847
881
|
const updatedState = this.setFieldByPath(
|
|
848
882
|
fullState as Record<string, unknown>,
|
|
@@ -879,7 +913,7 @@ class LensServerImpl<
|
|
|
879
913
|
})();
|
|
880
914
|
};
|
|
881
915
|
|
|
882
|
-
return createEmit<unknown>(emitHandler,
|
|
916
|
+
return createEmit<unknown>(emitHandler, outputType);
|
|
883
917
|
};
|
|
884
918
|
}
|
|
885
919
|
|
|
@@ -928,7 +962,7 @@ class LensServerImpl<
|
|
|
928
962
|
select?: SelectionObject,
|
|
929
963
|
context?: TContext,
|
|
930
964
|
onCleanup?: (fn: () => void) => void,
|
|
931
|
-
createFieldEmit?: (fieldPath: string) => Emit<unknown> | undefined,
|
|
965
|
+
createFieldEmit?: (fieldPath: string, resolvedValue?: unknown) => Emit<unknown> | undefined,
|
|
932
966
|
): Promise<T> {
|
|
933
967
|
if (!data) return data;
|
|
934
968
|
|
|
@@ -966,7 +1000,7 @@ class LensServerImpl<
|
|
|
966
1000
|
context?: TContext,
|
|
967
1001
|
fieldPath = "",
|
|
968
1002
|
onCleanup?: (fn: () => void) => void,
|
|
969
|
-
createFieldEmit?: (fieldPath: string) => Emit<unknown> | undefined,
|
|
1003
|
+
createFieldEmit?: (fieldPath: string, resolvedValue?: unknown) => Emit<unknown> | undefined,
|
|
970
1004
|
): Promise<T> {
|
|
971
1005
|
if (!data || !this.resolverMap) return data;
|
|
972
1006
|
|
|
@@ -1053,7 +1087,8 @@ class LensServerImpl<
|
|
|
1053
1087
|
const publisher = resolverDef.subscribeField(field, obj, args, context ?? {});
|
|
1054
1088
|
if (publisher && createFieldEmit && onCleanup) {
|
|
1055
1089
|
try {
|
|
1056
|
-
|
|
1090
|
+
// Pass resolved value to determine correct emit type (array/object/scalar)
|
|
1091
|
+
const fieldEmit = createFieldEmit(currentPath, result[field]);
|
|
1057
1092
|
if (fieldEmit) {
|
|
1058
1093
|
publisher({
|
|
1059
1094
|
emit: fieldEmit,
|