@factoredui/core 0.3.0 → 0.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.
@@ -32,9 +32,10 @@ var DEFAULT_CONFIG = {
32
32
  supabaseAnonKey: "<your-anon-key>",
33
33
  schema: "factoredui"
34
34
  };
35
- function main() {
35
+ function runInit() {
36
36
  const targetDir = process.cwd();
37
37
  copyMigrations(targetDir);
38
+ copyEdgeFunction(targetDir);
38
39
  writeConfig(targetDir);
39
40
  printSetupInstructions();
40
41
  }
@@ -54,7 +55,7 @@ function copyMigrations(targetDir) {
54
55
  const timestamp = generateTimestamp();
55
56
  for (const file of migrationFiles) {
56
57
  const sourcePath = path.join(migrationsSource, file);
57
- const targetFilename = `${timestamp}_auxi_${file}`;
58
+ const targetFilename = `${timestamp}_factoredui_${file}`;
58
59
  const targetPath = path.join(migrationsTarget, targetFilename);
59
60
  if (fs.existsSync(targetPath)) {
60
61
  console.log(` skip: ${targetFilename} (already exists)`);
@@ -75,6 +76,36 @@ function findMigrationsDir() {
75
76
  "Could not find factoredui migrations directory. Ensure @factoredui/core is installed correctly."
76
77
  );
77
78
  }
79
+ function copyEdgeFunction(targetDir) {
80
+ const functionSource = findEdgeFunctionDir();
81
+ const functionTarget = path.join(targetDir, "supabase", "functions", "factoredui-cluster");
82
+ if (fs.existsSync(path.join(functionTarget, "index.ts"))) {
83
+ console.log("\nEdge function already exists \u2014 skipping.");
84
+ return;
85
+ }
86
+ fs.mkdirSync(functionTarget, { recursive: true });
87
+ const EDGE_FN_ALLOWED_FILES = /* @__PURE__ */ new Set([".ts", ".json"]);
88
+ const files = fs.readdirSync(functionSource).filter(
89
+ (file) => EDGE_FN_ALLOWED_FILES.has(path.extname(file))
90
+ );
91
+ for (const file of files) {
92
+ fs.copyFileSync(
93
+ path.join(functionSource, file),
94
+ path.join(functionTarget, file)
95
+ );
96
+ }
97
+ console.log(`
98
+ Copied edge function to supabase/functions/factoredui-cluster/`);
99
+ }
100
+ function findEdgeFunctionDir() {
101
+ const packageDir = path.resolve(__dirname, "..", "..", "supabase", "functions", "factoredui-cluster");
102
+ if (fs.existsSync(packageDir)) return packageDir;
103
+ const devDir = path.resolve(__dirname, "..", "supabase", "functions", "factoredui-cluster");
104
+ if (fs.existsSync(devDir)) return devDir;
105
+ throw new Error(
106
+ "Could not find factoredui-cluster edge function. Ensure @factoredui/core is installed correctly."
107
+ );
108
+ }
78
109
  function writeConfig(targetDir) {
79
110
  const configPath = path.join(targetDir, CONFIG_FILENAME);
80
111
  if (fs.existsSync(configPath)) {
@@ -95,13 +126,32 @@ function printSetupInstructions() {
95
126
  Setup complete! Next steps:
96
127
 
97
128
  1. Update ${CONFIG_FILENAME} with your Supabase URL and anon key
98
- 2. Run: npx supabase db push
99
- 3. In your app:
129
+
130
+ 2. Enable required Postgres extensions (if not already enabled):
131
+ - pg_cron (scheduled clustering jobs)
132
+ - pg_net (edge function invocation from pg_cron)
133
+ - vector (pgvector for factor embeddings)
134
+
135
+ 3. Add "factoredui" to your PostgREST schema config:
136
+ In supabase/config.toml:
137
+ [api]
138
+ schemas = ["public", "factoredui"]
139
+ Or set the env var: PGRST_DB_SCHEMAS=public,factoredui
140
+
141
+ 4. Apply migrations:
142
+ npx supabase db push
143
+
144
+ 5. Deploy the clustering edge function:
145
+ npx supabase functions deploy factoredui-cluster
146
+
147
+ 6. In your app:
100
148
 
101
149
  import { initCapture } from '@factoredui/core'
102
150
  import { createClient } from '@supabase/supabase-js'
103
151
 
104
- const supabase = createClient(url, anonKey)
152
+ const supabase = createClient(url, anonKey, {
153
+ db: { schema: 'factoredui' }
154
+ })
105
155
  const capture = initCapture({ supabase })
106
156
 
107
157
  For React Native / Expo:
@@ -113,4 +163,67 @@ Setup complete! Next steps:
113
163
  </Provider>
114
164
  `);
115
165
  }
166
+
167
+ // src/sdui/ed25519.ts
168
+ async function generateEd25519Keypair() {
169
+ const keypair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
170
+ const publicKeyBytes = await crypto.subtle.exportKey("raw", keypair.publicKey);
171
+ const privateKeyBytes = await crypto.subtle.exportKey("pkcs8", keypair.privateKey);
172
+ return {
173
+ publicKey: bytesToBase64(new Uint8Array(publicKeyBytes)),
174
+ privateKey: bytesToBase64(new Uint8Array(privateKeyBytes))
175
+ };
176
+ }
177
+ function bytesToBase64(bytes) {
178
+ let binary = "";
179
+ for (let i = 0; i < bytes.length; i++) {
180
+ binary += String.fromCharCode(bytes[i]);
181
+ }
182
+ return btoa(binary);
183
+ }
184
+
185
+ // src/cli/keygen.ts
186
+ async function runKeygen() {
187
+ const { publicKey, privateKey } = await generateEd25519Keypair();
188
+ console.log("Ed25519 keypair generated.\n");
189
+ console.log("Public key (embed in client app):");
190
+ console.log(` ${publicKey}
191
+ `);
192
+ console.log("Private key (keep secret, use server-side for signing specs):");
193
+ console.log(` ${privateKey}
194
+ `);
195
+ console.log("Store the private key securely (env var, secret manager).");
196
+ console.log("The public key goes in your app config for spec signature verification.");
197
+ }
198
+
199
+ // src/cli/cli.ts
200
+ var COMMANDS = {
201
+ init: runInit,
202
+ keygen: runKeygen
203
+ };
204
+ function main() {
205
+ const command = process.argv[2];
206
+ if (!command) {
207
+ printUsage();
208
+ process.exit(1);
209
+ }
210
+ const handler = COMMANDS[command];
211
+ if (!handler) {
212
+ console.error(`Unknown command: ${command}
213
+ `);
214
+ printUsage();
215
+ process.exit(1);
216
+ }
217
+ Promise.resolve(handler()).catch((err) => {
218
+ console.error(`factoredui ${command} failed:`, err.message);
219
+ process.exit(1);
220
+ });
221
+ }
222
+ function printUsage() {
223
+ console.log(`Usage: factoredui <command>
224
+
225
+ Commands:
226
+ init Copy migrations and create config file
227
+ keygen Generate Ed25519 keypair for SDUI spec signing`);
228
+ }
116
229
  main();
package/dist/index.cjs CHANGED
@@ -105,7 +105,7 @@ function createSessionManager(supabase, adapter, platform, timeoutMs = DEFAULT_T
105
105
  // src/capture/writer.ts
106
106
  var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
107
107
  var DEFAULT_FLUSH_BATCH_SIZE = 50;
108
- function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS, flushBatchSize = DEFAULT_FLUSH_BATCH_SIZE) {
108
+ function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS, flushBatchSize = DEFAULT_FLUSH_BATCH_SIZE, adapter) {
109
109
  let queue = [];
110
110
  let flushTimer = null;
111
111
  let isFlushing = false;
@@ -129,15 +129,37 @@ function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS
129
129
  const { error } = await supabase.from("events").insert(batch);
130
130
  if (error) {
131
131
  queue.unshift(...batch);
132
+ persistQueueToAdapter();
132
133
  console.error("factoredui: flush failed:", error.message);
133
134
  }
134
135
  } catch (err) {
135
136
  queue.unshift(...batch);
137
+ persistQueueToAdapter();
136
138
  console.error("factoredui: flush error:", err);
137
139
  } finally {
138
140
  isFlushing = false;
139
141
  }
140
142
  }
143
+ function persistQueueToAdapter() {
144
+ if (!adapter?.persistQueue || queue.length === 0) return;
145
+ try {
146
+ adapter.persistQueue(JSON.stringify(queue));
147
+ } catch {
148
+ }
149
+ }
150
+ function drainPersistedQueue() {
151
+ if (!adapter?.loadQueue) return;
152
+ try {
153
+ const serialized = adapter.loadQueue();
154
+ if (!serialized) return;
155
+ const persisted = JSON.parse(serialized);
156
+ if (persisted.length > 0) {
157
+ queue.unshift(...persisted);
158
+ adapter.persistQueue?.("");
159
+ }
160
+ } catch {
161
+ }
162
+ }
141
163
  function startAutoFlush() {
142
164
  if (flushTimer) return;
143
165
  flushTimer = setInterval(flush, flushIntervalMs);
@@ -148,7 +170,7 @@ function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS
148
170
  flushTimer = null;
149
171
  }
150
172
  }
151
- return { enqueue, flush, startAutoFlush, stopAutoFlush };
173
+ return { enqueue, flush, startAutoFlush, stopAutoFlush, drainPersistedQueue };
152
174
  }
153
175
 
154
176
  // src/capture/path.ts
@@ -225,6 +247,7 @@ function detectScrollReversal(state, scrollY, now) {
225
247
  var THROTTLE_INTERVAL_MS = 100;
226
248
  var DEAD_CLICK_WAIT_MS = 1e3;
227
249
  var SESSION_STORAGE_KEY = "factoredui:session_id";
250
+ var QUEUE_STORAGE_KEY = "factoredui:offline_queue";
228
251
  function createWebAdapter() {
229
252
  const handlers = /* @__PURE__ */ new Map();
230
253
  const rageClickState = createRageClickState();
@@ -309,9 +332,28 @@ function createWebAdapter() {
309
332
  storeSessionId,
310
333
  loadSessionId,
311
334
  clearSessionId,
312
- registerUnloadHandler
335
+ registerUnloadHandler,
336
+ persistQueue,
337
+ loadQueue
313
338
  };
314
339
  }
340
+ function persistQueue(serialized) {
341
+ try {
342
+ if (!serialized || serialized === "") {
343
+ localStorage.removeItem(QUEUE_STORAGE_KEY);
344
+ } else {
345
+ localStorage.setItem(QUEUE_STORAGE_KEY, serialized);
346
+ }
347
+ } catch {
348
+ }
349
+ }
350
+ function loadQueue() {
351
+ try {
352
+ return localStorage.getItem(QUEUE_STORAGE_KEY);
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
315
357
  function collectSessionMetadata() {
316
358
  if (typeof window === "undefined") return {};
317
359
  return {
@@ -833,10 +875,10 @@ function createSeededRng(seed) {
833
875
  }
834
876
 
835
877
  // src/experiment/targeting.ts
836
- function evaluateTargeting(factors, rules) {
878
+ function evaluateTargeting(factors, rules, deviceMetadata) {
837
879
  if (rules.length === 0) return true;
838
880
  const factorsByName = indexFactorsByName(factors);
839
- return rules.every((rule) => evaluateRule(factorsByName, rule));
881
+ return rules.every((rule) => evaluateRule(factorsByName, rule, deviceMetadata));
840
882
  }
841
883
  function indexFactorsByName(factors) {
842
884
  const indexed = /* @__PURE__ */ new Map();
@@ -845,12 +887,32 @@ function indexFactorsByName(factors) {
845
887
  }
846
888
  return indexed;
847
889
  }
848
- function evaluateRule(factorsByName, rule) {
849
- const value = factorsByName.get(rule.factor);
850
- if (value === void 0) return false;
851
- return compareValue(value, rule.operator, rule.threshold);
890
+ function isMetadataRule(rule) {
891
+ return "type" in rule && rule.type === "metadata";
892
+ }
893
+ function isFactorRule(rule) {
894
+ return !("type" in rule) || rule.type === "factor";
895
+ }
896
+ function evaluateRule(factorsByName, rule, deviceMetadata) {
897
+ if (isMetadataRule(rule)) {
898
+ return evaluateMetadataRule(rule, deviceMetadata);
899
+ }
900
+ if (isFactorRule(rule)) {
901
+ const threshold = "threshold" in rule ? rule.threshold : 0;
902
+ const factorName = rule.factor;
903
+ const value = factorsByName.get(factorName);
904
+ if (value === void 0) return false;
905
+ return compareNumeric(value, rule.operator, threshold);
906
+ }
907
+ return false;
908
+ }
909
+ function evaluateMetadataRule(rule, metadata) {
910
+ if (!metadata) return false;
911
+ const fieldValue = metadata[rule.field];
912
+ if (fieldValue === void 0 || fieldValue === null) return false;
913
+ return compareString(fieldValue, rule.operator, rule.value);
852
914
  }
853
- function compareValue(value, operator, threshold) {
915
+ function compareNumeric(value, operator, threshold) {
854
916
  switch (operator) {
855
917
  case "gt":
856
918
  return value > threshold;
@@ -864,9 +926,25 @@ function compareValue(value, operator, threshold) {
864
926
  return value === threshold;
865
927
  }
866
928
  }
929
+ function compareString(value, operator, target) {
930
+ const lower = value.toLowerCase();
931
+ const targetLower = target.toLowerCase();
932
+ switch (operator) {
933
+ case "eq":
934
+ return lower === targetLower;
935
+ case "neq":
936
+ return lower !== targetLower;
937
+ case "contains":
938
+ return lower.includes(targetLower);
939
+ case "gte":
940
+ return lower >= targetLower;
941
+ case "lte":
942
+ return lower <= targetLower;
943
+ }
944
+ }
867
945
 
868
946
  // src/experiment/flags.ts
869
- async function evaluateFlag(supabase, experimentName, platform) {
947
+ async function evaluateFlag(supabase, experimentName, platform, deviceMetadata) {
870
948
  const userId = await resolveUserId2(supabase);
871
949
  if (!userId) return null;
872
950
  const existingAssignment = await fetchAssignment(supabase, userId, experimentName);
@@ -874,7 +952,7 @@ async function evaluateFlag(supabase, experimentName, platform) {
874
952
  await recordExposure(supabase, userId, existingAssignment);
875
953
  return existingAssignment;
876
954
  }
877
- const newAssignment = await assignToExperiment(supabase, userId, experimentName, platform);
955
+ const newAssignment = await assignToExperiment(supabase, userId, experimentName, platform, deviceMetadata);
878
956
  if (newAssignment) {
879
957
  await recordExposure(supabase, userId, newAssignment);
880
958
  }
@@ -899,10 +977,17 @@ async function fetchAssignment(supabase, userId, experimentName) {
899
977
  config: row.experiment_variants?.config ?? {}
900
978
  };
901
979
  }
902
- async function assignToExperiment(supabase, userId, experimentName, platform) {
980
+ async function assignToExperiment(supabase, userId, experimentName, platform, deviceMetadata) {
903
981
  const experiment = await fetchRunningExperiment(supabase, experimentName, platform);
904
982
  if (!experiment) return null;
905
- const isTargeted = await checkTargeting(supabase, userId, experiment);
983
+ const hasConflict = await hasConflictingAssignment(
984
+ supabase,
985
+ userId,
986
+ experiment.component_path,
987
+ experiment.id
988
+ );
989
+ if (hasConflict) return null;
990
+ const isTargeted = await checkTargeting(supabase, userId, experiment, deviceMetadata);
906
991
  if (!isTargeted) return null;
907
992
  const variants = await fetchExperimentVariants(supabase, experiment.id);
908
993
  if (variants.length === 0) return null;
@@ -920,6 +1005,11 @@ async function assignToExperiment(supabase, userId, experimentName, platform) {
920
1005
  config: selectedVariant.config
921
1006
  };
922
1007
  }
1008
+ async function hasConflictingAssignment(supabase, userId, componentPath, currentExperimentId) {
1009
+ const { data, error } = await supabase.from("experiment_assignments").select("experiment_id, experiments!inner ( id, status, component_path )").eq("user_id", userId).eq("experiments.status", "running").eq("experiments.component_path", componentPath).neq("experiment_id", currentExperimentId).limit(1);
1010
+ if (error) return false;
1011
+ return (data?.length ?? 0) > 0;
1012
+ }
923
1013
  async function fetchRunningExperiment(supabase, experimentName, platform) {
924
1014
  const { data, error } = await supabase.from("experiments").select("id, name, component_path, targeting_rules, platforms").eq("name", experimentName).eq("status", "running").maybeSingle();
925
1015
  if (error || !data) return null;
@@ -929,10 +1019,10 @@ async function fetchRunningExperiment(supabase, experimentName, platform) {
929
1019
  }
930
1020
  return experiment;
931
1021
  }
932
- async function checkTargeting(supabase, userId, experiment) {
1022
+ async function checkTargeting(supabase, userId, experiment, deviceMetadata) {
933
1023
  if (!experiment.targeting_rules || experiment.targeting_rules.length === 0) return true;
934
1024
  const factors = await queryFactors(supabase, userId, experiment.component_path);
935
- return evaluateTargeting(factors, experiment.targeting_rules);
1025
+ return evaluateTargeting(factors, experiment.targeting_rules, deviceMetadata);
936
1026
  }
937
1027
  async function fetchExperimentVariants(supabase, experimentId) {
938
1028
  const { data, error } = await supabase.from("experiment_variants").select("variant_key, config, traffic_percentage").eq("experiment_id", experimentId).order("variant_key");
@@ -974,8 +1064,11 @@ async function createExperiment(client, definition) {
974
1064
  return experiment;
975
1065
  }
976
1066
  async function startExperiment(client, experimentId) {
977
- const { error } = await client.from("experiments").update({ status: "running" }).eq("id", experimentId).eq("status", "draft");
1067
+ const { data, error } = await client.from("experiments").update({ status: "running" }).eq("id", experimentId).eq("status", "draft").select("id");
978
1068
  if (error) throw new Error(`startExperiment failed: ${error.message}`);
1069
+ if (!data || data.length === 0) {
1070
+ throw new Error(`startExperiment: experiment ${experimentId} not found or not in draft status`);
1071
+ }
979
1072
  }
980
1073
  function validateDefinition(definition) {
981
1074
  if (definition.variants.length < 2) {