@sylphx/lens-server 2.12.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 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]: command.update.data
709
+ [command.field]: newValue
704
710
  };
705
711
  }
706
- return { [command.field]: command.update.data };
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] = update.update.data;
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
- const isArray = Array.isArray(currentFieldValue);
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 fieldValue = this.getFieldByPath(fullState, fieldPath);
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, isArray);
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.12.0",
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.10.0"
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
+ });
@@ -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]: command.update.data,
778
+ [command.field]: newValue,
771
779
  };
772
780
  }
773
- return { [command.field]: command.update.data };
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] = update.update.data;
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 if field value is an array (check current state)
842
+ // Determine output type from resolved value (if provided) or current field value
833
843
  const state = getCurrentState();
834
- const currentFieldValue = state ? this.getFieldByPath(state, fieldPath) : undefined;
835
- const isArray = Array.isArray(currentFieldValue);
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 and apply command to it
843
- const fieldValue = this.getFieldByPath(fullState, fieldPath);
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, isArray);
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
- const fieldEmit = createFieldEmit(currentPath);
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,