@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.
- package/dist/cli/{init.cjs → factoredui.cjs} +118 -5
- package/dist/index.cjs +110 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -10
- package/dist/index.d.ts +39 -10
- package/dist/index.js +110 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/supabase/migrations/20250101000026_rls_ui_specs.sql +9 -0
- package/supabase/migrations/20250101000027_devices.sql +44 -0
|
@@ -32,9 +32,10 @@ var DEFAULT_CONFIG = {
|
|
|
32
32
|
supabaseAnonKey: "<your-anon-key>",
|
|
33
33
|
schema: "factoredui"
|
|
34
34
|
};
|
|
35
|
-
function
|
|
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}
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
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
|
|
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) {
|