@seedprotocol/sdk 0.6.6 → 0.6.7
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/{BasePathResolver-BGxb4Qod.js → BasePathResolver-qV0apdVY.js} +124 -2
- package/dist/BasePathResolver-qV0apdVY.js.map +1 -0
- package/dist/{index-CJNKYI37.js → ClientManager-Y5EWQuJj.js} +1525 -5495
- package/dist/ClientManager-Y5EWQuJj.js.map +1 -0
- package/dist/{ModelProperty-Bvg9cJcd.js → ModelProperty-DuB5nk6A.js} +33 -30
- package/dist/ModelProperty-DuB5nk6A.js.map +1 -0
- package/dist/{Schema-BNJBY80k.js → Schema-BYM-GNdE.js} +40 -39
- package/dist/{Schema-BNJBY80k.js.map → Schema-BYM-GNdE.js.map} +1 -1
- package/dist/{SchemaValidationService-aqXkwXTr.js → SchemaValidationService-Dr6nmwPm.js} +12 -9
- package/dist/{SchemaValidationService-aqXkwXTr.js.map → SchemaValidationService-Dr6nmwPm.js.map} +1 -1
- package/dist/{backfillMetadataPropertyIds-DYkvoOMz.js → backfillMetadataPropertyIds-BzbAB-C5.js} +4 -3
- package/dist/{backfillMetadataPropertyIds-DYkvoOMz.js.map → backfillMetadataPropertyIds-BzbAB-C5.js.map} +1 -1
- package/dist/cjs/{ModelProperty-alntkSnH.js → ModelProperty-CDTOx5zi.js} +21 -20
- package/dist/cjs/{ModelProperty-alntkSnH.js.map → ModelProperty-CDTOx5zi.js.map} +1 -1
- package/dist/cjs/{Schema-ByFv6fcb.js → Schema-XXcwj3hV.js} +24 -23
- package/dist/cjs/{Schema-ByFv6fcb.js.map → Schema-XXcwj3hV.js.map} +1 -1
- package/dist/cjs/{SchemaValidationService-D55MWsxv.js → SchemaValidationService-Z7OhtGn5.js} +6 -5
- package/dist/cjs/{SchemaValidationService-D55MWsxv.js.map → SchemaValidationService-Z7OhtGn5.js.map} +1 -1
- package/dist/cjs/{backfillMetadataPropertyIds-DrrL5RqD.js → backfillMetadataPropertyIds-Bt4fzfIV.js} +6 -5
- package/dist/cjs/{backfillMetadataPropertyIds-DrrL5RqD.js.map → backfillMetadataPropertyIds-Bt4fzfIV.js.map} +1 -1
- package/dist/cjs/{eas-D2E3_i_c.js → eas-DdI-4vwl.js} +31 -6
- package/dist/cjs/eas-DdI-4vwl.js.map +1 -0
- package/dist/cjs/{getItem-B2zLVB_l.js → getItem-B_2GeTQf.js} +5 -4
- package/dist/cjs/{getItem-B2zLVB_l.js.map → getItem-B_2GeTQf.js.map} +1 -1
- package/dist/cjs/{getPublishPayload-Bv94k4ak.js → getPublishPayload-Jmgi1RUj.js} +13 -12
- package/dist/cjs/getPublishPayload-Jmgi1RUj.js.map +1 -0
- package/dist/cjs/{getPublishUploads-CSbHUxYk.js → getPublishUploads-DNULcrN5.js} +8 -7
- package/dist/cjs/{getPublishUploads-CSbHUxYk.js.map → getPublishUploads-DNULcrN5.js.map} +1 -1
- package/dist/cjs/{getSegmentedItemProperties-CMhXKQHz.js → getSegmentedItemProperties-DK82kjri.js} +3 -3
- package/dist/cjs/{getSegmentedItemProperties-CMhXKQHz.js.map → getSegmentedItemProperties-DK82kjri.js.map} +1 -1
- package/dist/cjs/{index-D-9WglxG.js → index-DM1WR83P.js} +5152 -4967
- package/dist/cjs/index-DM1WR83P.js.map +1 -0
- package/dist/cjs/{index-pSLjO-ch.js → index-tpO1Nj4v.js} +7 -6
- package/dist/cjs/index-tpO1Nj4v.js.map +1 -0
- package/dist/cjs/{property-gUK2PijH.js → property-DmvgA8FD.js} +8 -7
- package/dist/cjs/{property-gUK2PijH.js.map → property-DmvgA8FD.js.map} +1 -1
- package/dist/cjs/{syncDbWithEas-CYtl7VTD.js → syncDbWithEas-3hOoPdu1.js} +9 -8
- package/dist/cjs/{syncDbWithEas-CYtl7VTD.js.map → syncDbWithEas-3hOoPdu1.js.map} +1 -1
- package/dist/{configurePlatform-bGvKBBQy.js → configurePlatform-CCYQruij.js} +6 -3
- package/dist/{configurePlatform-bGvKBBQy.js.map → configurePlatform-CCYQruij.js.map} +1 -1
- package/dist/constants-DSqHBPNe.js +1347 -0
- package/dist/constants-DSqHBPNe.js.map +1 -0
- package/dist/{db-YiH_kIpE.js → db-CceyWksv.js} +29 -35
- package/dist/{db-YiH_kIpE.js.map → db-CceyWksv.js.map} +1 -1
- package/dist/{eas-1nUCcmeh.js → eas-CH8iFIST.js} +9 -6
- package/dist/eas-CH8iFIST.js.map +1 -0
- package/dist/generateId-BopUwD8F.js +9 -0
- package/dist/generateId-BopUwD8F.js.map +1 -0
- package/dist/{getItem-Bb8bTYzY.js → getItem-C7Mtfz6O.js} +11 -9
- package/dist/{getItem-Bb8bTYzY.js.map → getItem-C7Mtfz6O.js.map} +1 -1
- package/dist/{getPublishPayload-BHw8mPSb.js → getPublishPayload-2gnnDdMh.js} +23 -20
- package/dist/getPublishPayload-2gnnDdMh.js.map +1 -0
- package/dist/{getPublishUploads-BYhckxFl.js → getPublishUploads-AKISt3JA.js} +14 -11
- package/dist/{getPublishUploads-BYhckxFl.js.map → getPublishUploads-AKISt3JA.js.map} +1 -1
- package/dist/{getSegmentedItemProperties-B73nMPBv.js → getSegmentedItemProperties-eIjvhCP_.js} +5 -5
- package/dist/{getSegmentedItemProperties-B73nMPBv.js.map → getSegmentedItemProperties-eIjvhCP_.js.map} +1 -1
- package/dist/index-BFpbtEsg.js +3492 -0
- package/dist/index-BFpbtEsg.js.map +1 -0
- package/dist/{index-DzgMt8ba.js → index-CHi212ST.js} +15 -12
- package/dist/index-CHi212ST.js.map +1 -0
- package/dist/{index-C_5xwOPQ.js → index-CsmiM0RI.js} +2 -2
- package/dist/index-CsmiM0RI.js.map +1 -0
- package/dist/{index.node-ZLiBj9Cu.js → index.node-CbWB4MDz.js} +4 -5
- package/dist/{index.node-ZLiBj9Cu.js.map → index.node-CbWB4MDz.js.map} +1 -1
- package/dist/main.cjs +5 -4
- package/dist/main.cjs.map +1 -1
- package/dist/main.js +33 -31
- package/dist/main.js.map +1 -1
- package/dist/model-DGMe-TTm.js +25 -0
- package/dist/model-DGMe-TTm.js.map +1 -0
- package/dist/node.js +19 -16
- package/dist/node.js.map +1 -1
- package/dist/platform.browser.js +60 -23
- package/dist/platform.browser.js.map +1 -1
- package/dist/platform.node.js +4 -5
- package/dist/platform.node.js.map +1 -1
- package/dist/{property-Dcb50seV.js → property-CWKcZ5YZ.js} +18 -16
- package/dist/property-CWKcZ5YZ.js.map +1 -0
- package/dist/src/Item/Item.d.ts.map +1 -1
- package/dist/src/ItemProperty/ItemProperty.d.ts.map +1 -1
- package/dist/src/ItemProperty/service/actors/hydrateFromDb.d.ts.map +1 -1
- package/dist/src/ItemProperty/service/actors/loadOrCreateProperty.d.ts.map +1 -1
- package/dist/src/Model/service/actors/loadOrCreateModel.d.ts.map +1 -1
- package/dist/src/browser/db/Db.d.ts.map +1 -1
- package/dist/src/browser/db/embeddedMigrations.d.ts +17 -0
- package/dist/src/browser/db/embeddedMigrations.d.ts.map +1 -0
- package/dist/src/client/ClientManager.d.ts +18 -3
- package/dist/src/client/ClientManager.d.ts.map +1 -1
- package/dist/src/client/clientManagerMachine.d.ts +6 -1
- package/dist/src/client/clientManagerMachine.d.ts.map +1 -1
- package/dist/src/eas.d.ts +2 -2
- package/dist/src/eas.d.ts.map +1 -1
- package/dist/src/helpers/ArweaveClient/selectReadGateway.d.ts +1 -1
- package/dist/src/helpers/ArweaveClient/selectReadGateway.d.ts.map +1 -1
- package/dist/src/helpers/constants.d.ts +1 -2
- package/dist/src/helpers/constants.d.ts.map +1 -1
- package/dist/src/helpers/crypto.d.ts +3 -2
- package/dist/src/helpers/crypto.d.ts.map +1 -1
- package/dist/src/helpers/ensureImageLocal.d.ts +24 -0
- package/dist/src/helpers/ensureImageLocal.d.ts.map +1 -0
- package/dist/src/helpers/ethereumUtils.d.ts.map +1 -1
- package/dist/src/helpers/index.d.ts +10 -2
- package/dist/src/helpers/index.d.ts.map +1 -1
- package/dist/src/imports/json.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/{syncDbWithEas-B0RUeDuj.js → syncDbWithEas-5kZneTEL.js} +19 -17
- package/dist/syncDbWithEas-5kZneTEL.js.map +1 -0
- package/dist/{updateSeedRevokedAt-D5ZbBehZ.js → updateSeedRevokedAt-CgfOBtqJ.js} +2 -2
- package/dist/{updateSeedRevokedAt-D5ZbBehZ.js.map → updateSeedRevokedAt-CgfOBtqJ.js.map} +1 -1
- package/package.json +6 -6
- package/dist/BasePathResolver-BGxb4Qod.js.map +0 -1
- package/dist/ModelProperty-Bvg9cJcd.js.map +0 -1
- package/dist/cjs/eas-D2E3_i_c.js.map +0 -1
- package/dist/cjs/getPublishPayload-Bv94k4ak.js.map +0 -1
- package/dist/cjs/index-D-9WglxG.js.map +0 -1
- package/dist/cjs/index-pSLjO-ch.js.map +0 -1
- package/dist/constants-Bre6dTJR.js +0 -122
- package/dist/constants-Bre6dTJR.js.map +0 -1
- package/dist/constants-CTs2RUrM.js +0 -657
- package/dist/constants-CTs2RUrM.js.map +0 -1
- package/dist/eas-1nUCcmeh.js.map +0 -1
- package/dist/getPublishPayload-BHw8mPSb.js.map +0 -1
- package/dist/index-CJNKYI37.js.map +0 -1
- package/dist/index-C_5xwOPQ.js.map +0 -1
- package/dist/index-DzgMt8ba.js.map +0 -1
- package/dist/property-Dcb50seV.js.map +0 -1
- package/dist/syncDbWithEas-B0RUeDuj.js.map +0 -1
|
@@ -0,0 +1,3492 @@
|
|
|
1
|
+
import { fromCallback, fromPromise, setup, assign, createActor } from 'xstate';
|
|
2
|
+
import { a as BaseDb, m as models, p as properties, G as isInternalSchema, i as modelSchemas, s as schemas } from './BasePathResolver-qV0apdVY.js';
|
|
3
|
+
import { eq, or } from 'drizzle-orm';
|
|
4
|
+
import { C as ClientManagerState, d as TProperty } from './constants-DSqHBPNe.js';
|
|
5
|
+
import debug from 'debug';
|
|
6
|
+
import { generateId } from './generateId-BopUwD8F.js';
|
|
7
|
+
import { w as writeModelToDb, k as writePropertyToDb, l as addSchemaToDb, r as renameModelInDb } from './db-CceyWksv.js';
|
|
8
|
+
import { w as waitForEntityIdle, a8 as getInvariantSafePropertyDescriptor, m as findEntity, B as getClient, I as Item, a9 as unloadEntity, aa as clearDestroySubscriptions, n as forceRemoveFromCaches, o as runDestroyLifecycle } from './ClientManager-Y5EWQuJj.js';
|
|
9
|
+
import { modelPropertiesToObject } from './model-DGMe-TTm.js';
|
|
10
|
+
import { toSnakeCase } from 'drizzle-orm/casing';
|
|
11
|
+
import { Type } from '@sinclair/typebox';
|
|
12
|
+
|
|
13
|
+
const logger$7 = debug('seedSdk:model:actors:loadOrCreateModel');
|
|
14
|
+
/**
|
|
15
|
+
* Create ModelProperty instances for all property IDs to ensure they're cached
|
|
16
|
+
* This ensures that ModelProperty.getById() in Model.properties getter will find the instances
|
|
17
|
+
* @param propertyFileIds - Array of property file IDs to create instances for
|
|
18
|
+
*/
|
|
19
|
+
const createPropertyInstances = async (propertyFileIds) => {
|
|
20
|
+
if (propertyFileIds.length === 0) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const mod = await import('./ModelProperty-DuB5nk6A.js');
|
|
25
|
+
const ModelProperty = mod?.ModelProperty ?? mod?.default;
|
|
26
|
+
if (!ModelProperty) {
|
|
27
|
+
logger$7('createPropertyInstances: ModelProperty not available from dynamic import');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Create instances for all property IDs in parallel
|
|
31
|
+
// ModelProperty.createById() will check cache first, then query DB and create if needed
|
|
32
|
+
const createPromises = propertyFileIds.map(async (propertyFileId) => {
|
|
33
|
+
try {
|
|
34
|
+
const property = await ModelProperty.createById(propertyFileId);
|
|
35
|
+
if (property) {
|
|
36
|
+
logger$7(`Created/cached ModelProperty instance for propertyFileId "${propertyFileId}"`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
logger$7(`ModelProperty.createById returned undefined for propertyFileId "${propertyFileId}" (may not exist in DB yet)`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
logger$7(`Error creating ModelProperty instance for propertyFileId "${propertyFileId}": ${error}`);
|
|
44
|
+
// Don't throw - continue with other properties
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
await Promise.all(createPromises);
|
|
48
|
+
logger$7(`Finished creating/caching ${propertyFileIds.length} ModelProperty instances`);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
logger$7(`Error in createPropertyInstances: ${error}`);
|
|
52
|
+
// Don't throw - this is best-effort to pre-populate cache
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const loadOrCreateModel = fromCallback(({ sendBack, input: { context } }) => {
|
|
56
|
+
const _loadOrCreateModel = async () => {
|
|
57
|
+
const { modelName, schemaName, id, _idFromSchema } = context; // id is now the schemaFileId (string)
|
|
58
|
+
if (!modelName || !schemaName) {
|
|
59
|
+
throw new Error('Model name and schema name are required');
|
|
60
|
+
}
|
|
61
|
+
// CRITICAL: Model instances load their data from database, not from Schema context.
|
|
62
|
+
// Schema is read-only with respect to Model instances.
|
|
63
|
+
const db = BaseDb.getAppDb();
|
|
64
|
+
let schemaFileId = id; // id is now the schemaFileId (string)
|
|
65
|
+
let modelRecord = null;
|
|
66
|
+
let foundBySchemaFileId = false; // Track if model was found by schemaFileId
|
|
67
|
+
let existingNumbersFromSchemaContext = null; // When we fall through (same name, different id), schema model occupies base name
|
|
68
|
+
// Step 1: Load from database FIRST (primary source of truth)
|
|
69
|
+
if (db) {
|
|
70
|
+
try {
|
|
71
|
+
// Try to find model by schemaFileId if provided
|
|
72
|
+
if (schemaFileId) {
|
|
73
|
+
const dbModels = await db
|
|
74
|
+
.select()
|
|
75
|
+
.from(models)
|
|
76
|
+
.where(eq(models.schemaFileId, schemaFileId))
|
|
77
|
+
.limit(1);
|
|
78
|
+
if (dbModels.length > 0) {
|
|
79
|
+
modelRecord = dbModels[0];
|
|
80
|
+
foundBySchemaFileId = true;
|
|
81
|
+
logger$7(`Found model "${modelName}" in database by schemaFileId "${schemaFileId}"`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// If not found by ID, try by name
|
|
85
|
+
// But if we have a schemaFileId and the model found by name has a different schemaFileId,
|
|
86
|
+
// don't use it - we're creating a new model from a schema file with a specific ID
|
|
87
|
+
if (!modelRecord) {
|
|
88
|
+
const dbModels = await db
|
|
89
|
+
.select()
|
|
90
|
+
.from(models)
|
|
91
|
+
.where(eq(models.name, modelName))
|
|
92
|
+
.limit(1);
|
|
93
|
+
if (dbModels.length > 0) {
|
|
94
|
+
const foundModel = dbModels[0];
|
|
95
|
+
const dbSchemaFileId = foundModel.schemaFileId;
|
|
96
|
+
// CRITICAL: If we found a model in the database by name, check if there's already a cached instance
|
|
97
|
+
// with that schemaFileId. If so, we should use that instance's schemaFileId for the current instance.
|
|
98
|
+
// This handles the case where Model.create was called with a generated ID, but the model
|
|
99
|
+
// already exists in the database with a different ID. By updating the current instance's
|
|
100
|
+
// schemaFileId to match the database, both will point to the same cached instance.
|
|
101
|
+
if (dbSchemaFileId) {
|
|
102
|
+
try {
|
|
103
|
+
const modelMod = await Promise.resolve().then(function () { return Model$1; });
|
|
104
|
+
const { Model } = modelMod;
|
|
105
|
+
// Access instanceCacheById via type assertion since it's protected
|
|
106
|
+
const cacheById = Model.instanceCacheById;
|
|
107
|
+
if (cacheById.has(dbSchemaFileId)) {
|
|
108
|
+
logger$7(`Model "${modelName}" found in database by name with schemaFileId "${dbSchemaFileId}", and a cached instance already exists. Updating current instance to use the same schemaFileId.`);
|
|
109
|
+
// Update the current instance's schemaFileId to match the database
|
|
110
|
+
// This ensures both instances point to the same cached instance
|
|
111
|
+
modelRecord = foundModel;
|
|
112
|
+
schemaFileId = dbSchemaFileId;
|
|
113
|
+
logger$7(`Using existing cached instance with schemaFileId "${schemaFileId}" for model "${modelName}"`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
logger$7(`Error checking Model cache for schemaFileId "${dbSchemaFileId}": ${error}`);
|
|
118
|
+
// Fall through to normal processing
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// If we have a schemaFileId and it doesn't match the found model's schemaFileId,
|
|
122
|
+
// this is a new model - don't use the existing model (will be renamed if duplicate)
|
|
123
|
+
// UNLESS we found a cached instance above (in which case modelRecord is already set)
|
|
124
|
+
if (!modelRecord && schemaFileId && dbSchemaFileId && schemaFileId !== dbSchemaFileId) {
|
|
125
|
+
logger$7(`Model "${modelName}" found by name but has different schemaFileId (provided: "${schemaFileId}", db: "${dbSchemaFileId}"). Creating new model (will check for duplicates).`);
|
|
126
|
+
// Don't set modelRecord - we'll create a new model with potentially renamed name
|
|
127
|
+
}
|
|
128
|
+
else if (!modelRecord && schemaFileId && !dbSchemaFileId) {
|
|
129
|
+
// We have a schemaFileId but the found model doesn't have one - this is a new model
|
|
130
|
+
// Don't reuse the existing model, create a new one (will be renamed if duplicate)
|
|
131
|
+
logger$7(`Model "${modelName}" found by name but found model has no schemaFileId. Creating new model with schemaFileId "${schemaFileId}" (will check for duplicates).`);
|
|
132
|
+
// Don't set modelRecord - we'll create a new model
|
|
133
|
+
}
|
|
134
|
+
else if (!modelRecord) {
|
|
135
|
+
// Only reuse existing model if:
|
|
136
|
+
// 1. No schemaFileId was provided (loading existing model), OR
|
|
137
|
+
// 2. schemaFileId matches (same model)
|
|
138
|
+
logger$7(`Model "${modelName}" found by name, reusing existing model (schemaFileId: provided="${schemaFileId}", db="${dbSchemaFileId}")`);
|
|
139
|
+
modelRecord = foundModel;
|
|
140
|
+
// Only use database schemaFileId if no id was explicitly provided
|
|
141
|
+
// If an id was provided, we should use it (it might be creating a new model with a specific ID)
|
|
142
|
+
if (!id && dbSchemaFileId) {
|
|
143
|
+
schemaFileId = dbSchemaFileId;
|
|
144
|
+
logger$7(`Using database schemaFileId "${schemaFileId}" for model "${modelName}" (no ID was provided)`);
|
|
145
|
+
}
|
|
146
|
+
else if (id) {
|
|
147
|
+
logger$7(`Preserving provided id (schemaFileId) "${schemaFileId}" for model "${modelName}" (ignoring database schemaFileId "${dbSchemaFileId}")`);
|
|
148
|
+
}
|
|
149
|
+
logger$7(`Found model "${modelName}" in database by name, schemaFileId: "${schemaFileId}"`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// If we found the model record, load its properties from database
|
|
154
|
+
if (modelRecord && modelRecord.id) {
|
|
155
|
+
const propertyRecords = await db
|
|
156
|
+
.select()
|
|
157
|
+
.from(properties)
|
|
158
|
+
.where(eq(properties.modelId, modelRecord.id));
|
|
159
|
+
// Properties are now loaded via liveQuery, not passed through context
|
|
160
|
+
// The property records are used by liveQuery to populate ModelProperty instances
|
|
161
|
+
// Pre-create ModelProperty instances for all property IDs to ensure they're cached
|
|
162
|
+
// This ensures that ModelProperty.getById() in Model.properties getter will find the instances
|
|
163
|
+
const propertyFileIds = propertyRecords
|
|
164
|
+
.map((record) => record.schemaFileId)
|
|
165
|
+
.filter((id) => id !== null && id !== undefined);
|
|
166
|
+
if (propertyFileIds.length > 0) {
|
|
167
|
+
await createPropertyInstances(propertyFileIds);
|
|
168
|
+
}
|
|
169
|
+
// Generate schemaFileId if not set
|
|
170
|
+
if (!schemaFileId) {
|
|
171
|
+
schemaFileId = generateId();
|
|
172
|
+
logger$7(`Generated id (schemaFileId) "${schemaFileId}" for model "${modelName}"`);
|
|
173
|
+
}
|
|
174
|
+
// Include _dbId from database record so properties can be created if needed
|
|
175
|
+
// Even if we found an existing model, we may still need to create properties
|
|
176
|
+
// if _pendingPropertyDefinitions are provided
|
|
177
|
+
// Track conflict detection metadata
|
|
178
|
+
const loadedAt = Date.now();
|
|
179
|
+
// Note: models table doesn't have updatedAt or version, so we use loadedAt for _dbUpdatedAt
|
|
180
|
+
// In the future, if models table gets these fields, we should use them
|
|
181
|
+
const dbUpdatedAt = loadedAt;
|
|
182
|
+
// CRITICAL: Include _liveQueryPropertyIds in loadOrCreateModelSuccess event
|
|
183
|
+
// This ensures the properties getter works immediately, even before _setupLiveQuerySubscription runs
|
|
184
|
+
// We already have the property IDs from the database query, so include them now
|
|
185
|
+
sendBack({
|
|
186
|
+
type: 'loadOrCreateModelSuccess',
|
|
187
|
+
model: {
|
|
188
|
+
id: schemaFileId, // schemaFileId (string) - public ID
|
|
189
|
+
_dbId: modelRecord.id, // Database integer ID - internal only
|
|
190
|
+
_liveQueryPropertyIds: propertyFileIds, // Property IDs from database query
|
|
191
|
+
_propertiesUpdated: Date.now(), // Timestamp for tracking
|
|
192
|
+
_isEdited: modelRecord.isEdited ?? false, // Load isEdited from database
|
|
193
|
+
_loadedAt: loadedAt,
|
|
194
|
+
_dbUpdatedAt: dbUpdatedAt,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
logger$7(`Error loading model from database: ${error}`);
|
|
202
|
+
// Fall through to create new model
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Step 2: Fallback to Schema context (only if database doesn't have the model)
|
|
206
|
+
// This handles the case where model exists in schema file but not yet in database
|
|
207
|
+
try {
|
|
208
|
+
const _mod_0 = await import('./Schema-BYM-GNdE.js');
|
|
209
|
+
const _ns_0 = _mod_0.b;
|
|
210
|
+
const schemaMod = _ns_0;
|
|
211
|
+
const { Schema } = schemaMod;
|
|
212
|
+
const schema = Schema.create(schemaName, { waitForReady: false });
|
|
213
|
+
const schemaSnapshot = schema.getService().getSnapshot();
|
|
214
|
+
// Wait for schema to load if it's still loading
|
|
215
|
+
// Check if state is a loading state object (XState v5 nested states)
|
|
216
|
+
const isLoading = typeof schemaSnapshot.value === 'object' && 'loading' in schemaSnapshot.value;
|
|
217
|
+
if (isLoading) {
|
|
218
|
+
await new Promise((resolve, reject) => {
|
|
219
|
+
const subscription = schema.getService().subscribe((snapshot) => {
|
|
220
|
+
if (snapshot.value === 'idle' || snapshot.value === 'error') {
|
|
221
|
+
subscription.unsubscribe();
|
|
222
|
+
if (snapshot.value === 'error') {
|
|
223
|
+
reject(new Error('Schema failed to load'));
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
resolve();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const schemaContext = schema.getService().getSnapshot().context;
|
|
233
|
+
// Check by name first; also check by schemaFileId so we recognize the schema model even when context is keyed differently or not yet updated
|
|
234
|
+
if (schemaContext.models) {
|
|
235
|
+
const byName = schemaContext.models[modelName];
|
|
236
|
+
const schemaModelId = byName?.id ?? null;
|
|
237
|
+
if (schemaFileId === schemaModelId) {
|
|
238
|
+
logger$7(`Found model "${modelName}" in Schema context (database fallback)`);
|
|
239
|
+
if (!schemaFileId) {
|
|
240
|
+
schemaFileId = generateId();
|
|
241
|
+
logger$7(`Generated id (schemaFileId) "${schemaFileId}" for model "${modelName}"`);
|
|
242
|
+
}
|
|
243
|
+
sendBack({
|
|
244
|
+
type: 'loadOrCreateModelSuccess',
|
|
245
|
+
model: { id: schemaFileId },
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Same name but different id: runtime create colliding with schema model name; fall through
|
|
250
|
+
if (byName) {
|
|
251
|
+
existingNumbersFromSchemaContext = new Set([0]);
|
|
252
|
+
}
|
|
253
|
+
else if (schemaFileId) {
|
|
254
|
+
// Schema context may not have model by name yet (e.g. still loading). If our id appears in any schema model, we're the schema model.
|
|
255
|
+
for (const def of Object.values(schemaContext.models)) {
|
|
256
|
+
if (def?.id === schemaFileId) {
|
|
257
|
+
logger$7(`Found model in Schema context by id "${schemaFileId}" (database fallback)`);
|
|
258
|
+
sendBack({
|
|
259
|
+
type: 'loadOrCreateModelSuccess',
|
|
260
|
+
model: { id: schemaFileId },
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
logger$7(`Error loading model from Schema context: ${error}`);
|
|
270
|
+
// Fall through to create new model
|
|
271
|
+
}
|
|
272
|
+
// Step 3: Check for duplicate names in database before creating new model
|
|
273
|
+
// Skip duplicate check only if we found a model record to use (modelRecord is set)
|
|
274
|
+
// OR if the model was found by schemaFileId (preserving original name from schema file)
|
|
275
|
+
// OR if this is an internal schema (Seed Protocol) - internal schemas should preserve original names
|
|
276
|
+
// Otherwise, check for duplicates when creating a new model
|
|
277
|
+
// NOTE: We should NOT skip duplicate check just because schemaFileId exists - that could be a newly generated ID
|
|
278
|
+
// for a new model that needs to be renamed if it's a duplicate
|
|
279
|
+
let finalModelName = modelName;
|
|
280
|
+
if (db) {
|
|
281
|
+
// Check if this is an internal schema (Seed Protocol)
|
|
282
|
+
const isInternal = isInternalSchema(schemaName);
|
|
283
|
+
// Only skip duplicate check if:
|
|
284
|
+
// 1. We found a model record to use (modelRecord is set), OR
|
|
285
|
+
// 2. The model was found by schemaFileId (preserving original name from schema file), OR
|
|
286
|
+
// 3. This is an internal schema (internal schemas should preserve original names from schema files), OR
|
|
287
|
+
// 4. This instance was created with modelFileId from schema (_idFromSchema) - schema models keep their name
|
|
288
|
+
const shouldSkipDuplicateCheck = modelRecord !== null || foundBySchemaFileId || isInternal || !!_idFromSchema;
|
|
289
|
+
logger$7(`Duplicate check: modelRecord=${modelRecord !== null}, foundBySchemaFileId=${foundBySchemaFileId}, isInternal=${isInternal}, _idFromSchema=${!!_idFromSchema}, schemaFileId=${schemaFileId}, shouldSkip=${shouldSkipDuplicateCheck}`);
|
|
290
|
+
if (!shouldSkipDuplicateCheck) {
|
|
291
|
+
try {
|
|
292
|
+
const lowerModelName = modelName.toLowerCase();
|
|
293
|
+
const existingNumbers = new Set(existingNumbersFromSchemaContext ?? []);
|
|
294
|
+
// First, check Model cache for models (includes models from imported schemas that may not be in DB yet)
|
|
295
|
+
try {
|
|
296
|
+
const modelMod = await Promise.resolve().then(function () { return Model$1; });
|
|
297
|
+
const { Model } = modelMod;
|
|
298
|
+
// Check name-based cache for this schema
|
|
299
|
+
// Skip the current model (context.id): Model.create() adds the new instance to the cache
|
|
300
|
+
// before this callback runs, so we must not count it as a duplicate.
|
|
301
|
+
logger$7(`Checking Model cache for duplicates in schema "${schemaName}"`);
|
|
302
|
+
const currentModelFileId = schemaFileId ?? null;
|
|
303
|
+
const cacheByName = Model.instanceCacheByName;
|
|
304
|
+
for (const [nameKey, cachedModelFileId] of cacheByName.entries()) {
|
|
305
|
+
if (currentModelFileId && cachedModelFileId === currentModelFileId)
|
|
306
|
+
continue; // skip self
|
|
307
|
+
const [cachedSchemaName, cachedModelName] = nameKey.split(':', 2);
|
|
308
|
+
if (cachedSchemaName === schemaName && cachedModelName) {
|
|
309
|
+
const lowerCachedName = cachedModelName.toLowerCase();
|
|
310
|
+
if (lowerCachedName === lowerModelName) {
|
|
311
|
+
existingNumbers.add(0); // Base name exists
|
|
312
|
+
logger$7(`Found duplicate in Model cache: "${cachedModelName}" matches "${modelName}"`);
|
|
313
|
+
}
|
|
314
|
+
else if (lowerCachedName.startsWith(lowerModelName + ' ')) {
|
|
315
|
+
// Check if it's the base name followed by a space and a number
|
|
316
|
+
const suffix = lowerCachedName.slice(lowerModelName.length + 1);
|
|
317
|
+
const number = parseInt(suffix, 10);
|
|
318
|
+
if (!isNaN(number) && suffix === number.toString()) {
|
|
319
|
+
existingNumbers.add(number);
|
|
320
|
+
logger$7(`Found numbered variant in Model cache: "${cachedModelName}" (number: ${number})`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Also check legacy cache (skip current model by id)
|
|
326
|
+
const legacyCache = Model.instanceCache;
|
|
327
|
+
for (const [nameKey, entry] of legacyCache?.entries() || []) {
|
|
328
|
+
const legacyId = entry?.instance?._getSnapshotContext?.()?.id;
|
|
329
|
+
if (currentModelFileId && legacyId === currentModelFileId)
|
|
330
|
+
continue; // skip self
|
|
331
|
+
const [cachedSchemaName, cachedModelName] = nameKey.split(':', 2);
|
|
332
|
+
if (cachedSchemaName === schemaName && cachedModelName) {
|
|
333
|
+
const lowerCachedName = cachedModelName.toLowerCase();
|
|
334
|
+
if (lowerCachedName === lowerModelName) {
|
|
335
|
+
existingNumbers.add(0); // Base name exists
|
|
336
|
+
logger$7(`Found duplicate in Model legacy cache: "${cachedModelName}" matches "${modelName}"`);
|
|
337
|
+
}
|
|
338
|
+
else if (lowerCachedName.startsWith(lowerModelName + ' ')) {
|
|
339
|
+
// Check if it's the base name followed by a space and a number
|
|
340
|
+
const suffix = lowerCachedName.slice(lowerModelName.length + 1);
|
|
341
|
+
const number = parseInt(suffix, 10);
|
|
342
|
+
if (!isNaN(number) && suffix === number.toString()) {
|
|
343
|
+
existingNumbers.add(number);
|
|
344
|
+
logger$7(`Found numbered variant in Model legacy cache: "${cachedModelName}" (number: ${number})`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
logger$7(`Error checking Model cache for duplicates: ${error}`);
|
|
352
|
+
// Continue with database check
|
|
353
|
+
}
|
|
354
|
+
// Also check database for models (in case they're persisted but not in Schema context)
|
|
355
|
+
if (db) {
|
|
356
|
+
const _mod_1 = await import('./BasePathResolver-qV0apdVY.js');
|
|
357
|
+
const _ns_1 = _mod_1.N;
|
|
358
|
+
const modelSchemaSchemaMod = _ns_1;
|
|
359
|
+
const { modelSchemas } = modelSchemaSchemaMod;
|
|
360
|
+
const _mod_2 = await import('./BasePathResolver-qV0apdVY.js');
|
|
361
|
+
const _ns_2 = _mod_2.L;
|
|
362
|
+
const schemaSchemaMod = _ns_2;
|
|
363
|
+
const { schemas: schemasTable } = schemaSchemaMod;
|
|
364
|
+
// Query all models for this schema to check for duplicates (case-insensitive)
|
|
365
|
+
logger$7(`Checking database for duplicate model names in schema "${schemaName}"`);
|
|
366
|
+
const allModelsForSchema = await db
|
|
367
|
+
.select({
|
|
368
|
+
name: models.name,
|
|
369
|
+
})
|
|
370
|
+
.from(models)
|
|
371
|
+
.innerJoin(modelSchemas, eq(models.id, modelSchemas.modelId))
|
|
372
|
+
.innerJoin(schemasTable, eq(modelSchemas.schemaId, schemasTable.id))
|
|
373
|
+
.where(eq(schemasTable.name, schemaName));
|
|
374
|
+
logger$7(`Found ${allModelsForSchema.length} models in database for schema "${schemaName}": ${allModelsForSchema.map((m) => m.name).join(', ')}`);
|
|
375
|
+
if (allModelsForSchema.length === 0 && modelName.includes('Shared')) {
|
|
376
|
+
logger$7(`[DEBUG] No models found in database for schema "${schemaName}" when checking for duplicate "${modelName}"`);
|
|
377
|
+
}
|
|
378
|
+
// Check for exact match (case-insensitive) or matches with number suffix
|
|
379
|
+
for (const dbModel of allModelsForSchema) {
|
|
380
|
+
const lowerDbName = dbModel.name.toLowerCase();
|
|
381
|
+
if (lowerDbName === lowerModelName) {
|
|
382
|
+
existingNumbers.add(0); // Base name exists
|
|
383
|
+
logger$7(`Found duplicate in database: "${dbModel.name}" matches "${modelName}"`);
|
|
384
|
+
}
|
|
385
|
+
else if (lowerDbName.startsWith(lowerModelName + ' ')) {
|
|
386
|
+
// Check if it's the base name followed by a space and a number
|
|
387
|
+
const suffix = lowerDbName.slice(lowerModelName.length + 1);
|
|
388
|
+
const number = parseInt(suffix, 10);
|
|
389
|
+
if (!isNaN(number) && suffix === number.toString()) {
|
|
390
|
+
existingNumbers.add(number);
|
|
391
|
+
logger$7(`Found numbered variant in database: "${dbModel.name}" (number: ${number})`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// If duplicates found, generate unique name
|
|
397
|
+
logger$7(`Duplicate check results: existingNumbers=${Array.from(existingNumbers).join(', ')}, hasBaseName=${existingNumbers.has(0)}`);
|
|
398
|
+
if (existingNumbers.has(0)) {
|
|
399
|
+
let nextNumber = 1;
|
|
400
|
+
while (existingNumbers.has(nextNumber)) {
|
|
401
|
+
nextNumber++;
|
|
402
|
+
}
|
|
403
|
+
finalModelName = `${modelName} ${nextNumber}`;
|
|
404
|
+
logger$7(`Found duplicate model name "${modelName}" in schema "${schemaName}", using unique name "${finalModelName}"`);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
logger$7(`No duplicate found for model name "${modelName}" in schema "${schemaName}"`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
logger$7(`Error checking for duplicate model names: ${error}`);
|
|
412
|
+
// Continue with original name if check fails
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
if (foundBySchemaFileId) {
|
|
417
|
+
logger$7(`Preserving original model name "${modelName}" for model found by schemaFileId (schemaFileId: ${schemaFileId})`);
|
|
418
|
+
}
|
|
419
|
+
else if (modelRecord) {
|
|
420
|
+
logger$7(`Using existing model name "${modelName}" from database (modelRecord found)`);
|
|
421
|
+
}
|
|
422
|
+
else if (isInternal) {
|
|
423
|
+
logger$7(`Preserving original model name "${modelName}" for internal schema "${schemaName}" (skipping duplicate check)`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Step 4: Create new empty model (not found in database or Schema context)
|
|
428
|
+
logger$7(`Creating new model "${finalModelName}" in schema "${schemaName}"`);
|
|
429
|
+
// Generate schemaFileId for new model
|
|
430
|
+
if (!schemaFileId) {
|
|
431
|
+
schemaFileId = generateId();
|
|
432
|
+
logger$7(`Generated id (schemaFileId) "${schemaFileId}" for new model "${finalModelName}"`);
|
|
433
|
+
}
|
|
434
|
+
// Runtime creates use a placeholder cache key in Model.create; move it to the final name. Skip when schema model (_idFromSchema).
|
|
435
|
+
if (!_idFromSchema) {
|
|
436
|
+
const modelMod = await Promise.resolve().then(function () { return Model$1; });
|
|
437
|
+
const { Model } = modelMod;
|
|
438
|
+
Model.updateNameIndex('__pending__' + schemaFileId, finalModelName, schemaName, schemaFileId);
|
|
439
|
+
}
|
|
440
|
+
// If model name was changed, store it in a temporary internal field so loadOrCreateModelSuccess can apply it
|
|
441
|
+
// This avoids the issue where updateContext with modelName triggers validation and state transition
|
|
442
|
+
// We use _pendingModelName (internal field) which won't trigger validation
|
|
443
|
+
if (finalModelName !== modelName) {
|
|
444
|
+
logger$7(`Model name changed from "${modelName}" to "${finalModelName}", storing in _pendingModelName for loadOrCreateModelSuccess`);
|
|
445
|
+
// Store the final name in a temporary internal field that will be picked up by loadOrCreateModelSuccess
|
|
446
|
+
sendBack({
|
|
447
|
+
type: 'updateContext',
|
|
448
|
+
_pendingModelName: finalModelName,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
logger$7(`Model name unchanged: "${modelName}"`);
|
|
453
|
+
}
|
|
454
|
+
// Mark schema as draft when a new model is created so saveNewVersion() can persist it
|
|
455
|
+
try {
|
|
456
|
+
const _mod_3 = await import('./Schema-BYM-GNdE.js');
|
|
457
|
+
const _ns_3 = _mod_3.b;
|
|
458
|
+
const schemaMod = _ns_3;
|
|
459
|
+
const { Schema } = schemaMod;
|
|
460
|
+
const schema = Schema.create(schemaName, { waitForReady: false });
|
|
461
|
+
schema.getService().send({ type: 'markAsDraft', propertyKey: 'schema:models' });
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
logger$7(`Failed to mark schema as draft after creating model: ${err}`);
|
|
465
|
+
}
|
|
466
|
+
sendBack({
|
|
467
|
+
type: 'loadOrCreateModelSuccess',
|
|
468
|
+
model: {
|
|
469
|
+
id: schemaFileId, // schemaFileId (string) - public ID
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
_loadOrCreateModel().catch((error) => {
|
|
474
|
+
logger$7('Error loading or creating model:', error);
|
|
475
|
+
sendBack({ type: 'loadOrCreateModelError', error });
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const logger$6 = debug('seedSdk:model:actors:validateModel');
|
|
480
|
+
// Validation timeout in milliseconds (10 seconds)
|
|
481
|
+
const VALIDATION_TIMEOUT_MS = 10000;
|
|
482
|
+
const validateModel = fromCallback(({ sendBack, input: { context } }) => {
|
|
483
|
+
let hasResponded = false;
|
|
484
|
+
// Timeout handler to ensure we always respond
|
|
485
|
+
const timeoutId = setTimeout(() => {
|
|
486
|
+
if (!hasResponded) {
|
|
487
|
+
hasResponded = true;
|
|
488
|
+
sendBack({
|
|
489
|
+
type: 'validationError',
|
|
490
|
+
errors: [{
|
|
491
|
+
field: 'model',
|
|
492
|
+
message: `Model validation timed out after ${VALIDATION_TIMEOUT_MS}ms`,
|
|
493
|
+
code: 'validation_timeout',
|
|
494
|
+
severity: 'error',
|
|
495
|
+
}],
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}, VALIDATION_TIMEOUT_MS);
|
|
499
|
+
const _validateModel = async () => {
|
|
500
|
+
try {
|
|
501
|
+
// Use dynamic imports to break circular dependencies
|
|
502
|
+
const validationServiceMod = await import('./SchemaValidationService-Dr6nmwPm.js');
|
|
503
|
+
const { SchemaValidationService } = validationServiceMod;
|
|
504
|
+
const validationService = new SchemaValidationService();
|
|
505
|
+
const _mod_4 = await import('./Schema-BYM-GNdE.js');
|
|
506
|
+
const _ns_4 = _mod_4.b;
|
|
507
|
+
const schemaMod = _ns_4;
|
|
508
|
+
const { Schema } = schemaMod;
|
|
509
|
+
// Validate model structure
|
|
510
|
+
const structureResult = validationService.validateModelStructure(context);
|
|
511
|
+
if (!structureResult.isValid) {
|
|
512
|
+
if (!hasResponded) {
|
|
513
|
+
hasResponded = true;
|
|
514
|
+
clearTimeout(timeoutId);
|
|
515
|
+
sendBack({ type: 'validationError', errors: structureResult.errors });
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// If we have schema name, validate against schema
|
|
520
|
+
// CRITICAL: Use validateModelAgainstSchema which doesn't require the model to be in schema context
|
|
521
|
+
// This allows validation BEFORE registration, preventing update loops
|
|
522
|
+
if (context.schemaName) {
|
|
523
|
+
try {
|
|
524
|
+
const schema = Schema.create(context.schemaName, {
|
|
525
|
+
waitForReady: false,
|
|
526
|
+
});
|
|
527
|
+
const schemaSnapshot = schema.getService().getSnapshot();
|
|
528
|
+
const schemaStatus = schemaSnapshot.value;
|
|
529
|
+
// Only validate against schema if it's loaded (in idle state)
|
|
530
|
+
if (schemaStatus === 'idle') {
|
|
531
|
+
const schemaContext = schemaSnapshot.context;
|
|
532
|
+
// Use validateModelAgainstSchema which validates the model data directly
|
|
533
|
+
// without requiring it to be in schema.models. This prevents loops because:
|
|
534
|
+
// 1. Model validates against schema (model NOT in schema context yet)
|
|
535
|
+
// 2. If valid, model gets registered with schema
|
|
536
|
+
// 3. No need for manual updates because model is already correct (liveQuery handles updates)
|
|
537
|
+
const schemaResult = validationService.validateModelAgainstSchema(schemaContext, context.modelName, context);
|
|
538
|
+
if (!schemaResult.isValid) {
|
|
539
|
+
if (!hasResponded) {
|
|
540
|
+
hasResponded = true;
|
|
541
|
+
clearTimeout(timeoutId);
|
|
542
|
+
sendBack({ type: 'validationError', errors: schemaResult.errors });
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
logger$6(`Schema is in ${schemaStatus} state, skipping schema validation`);
|
|
549
|
+
// Continue with structure validation only
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
logger$6('Error validating model against schema:', error);
|
|
554
|
+
// Continue with structure validation only
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// All validations passed
|
|
558
|
+
if (!hasResponded) {
|
|
559
|
+
hasResponded = true;
|
|
560
|
+
clearTimeout(timeoutId);
|
|
561
|
+
sendBack({ type: 'validationSuccess', errors: [] });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (!hasResponded) {
|
|
566
|
+
hasResponded = true;
|
|
567
|
+
clearTimeout(timeoutId);
|
|
568
|
+
logger$6('Error in validateModel:', error);
|
|
569
|
+
sendBack({
|
|
570
|
+
type: 'validationError',
|
|
571
|
+
errors: [{
|
|
572
|
+
field: 'model',
|
|
573
|
+
message: error instanceof Error ? error.message : 'Unknown validation error',
|
|
574
|
+
code: 'validation_exception',
|
|
575
|
+
severity: 'error',
|
|
576
|
+
}],
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
_validateModel().catch((error) => {
|
|
582
|
+
if (!hasResponded) {
|
|
583
|
+
hasResponded = true;
|
|
584
|
+
clearTimeout(timeoutId);
|
|
585
|
+
logger$6('Error in validateModel:', error);
|
|
586
|
+
sendBack({
|
|
587
|
+
type: 'validationError',
|
|
588
|
+
errors: [{
|
|
589
|
+
field: 'model',
|
|
590
|
+
message: error instanceof Error ? error.message : 'Unknown validation error',
|
|
591
|
+
code: 'validation_exception',
|
|
592
|
+
severity: 'error',
|
|
593
|
+
}],
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
return () => {
|
|
598
|
+
// Cleanup function - clear timeout if actor is stopped
|
|
599
|
+
clearTimeout(timeoutId);
|
|
600
|
+
};
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const logger$5 = debug('seedSdk:model:actors:createModelProperties');
|
|
604
|
+
const createModelProperties = fromCallback(({ sendBack, input }) => {
|
|
605
|
+
const _createProperties = async () => {
|
|
606
|
+
const { context, propertyDefinitions } = input;
|
|
607
|
+
const { id, _dbId, modelName } = context;
|
|
608
|
+
if (!id || !_dbId || !modelName) {
|
|
609
|
+
throw new Error('Model ID, file ID, and name are required to create properties');
|
|
610
|
+
}
|
|
611
|
+
if (!propertyDefinitions || Object.keys(propertyDefinitions).length === 0) {
|
|
612
|
+
logger$5('No property definitions provided, skipping property creation');
|
|
613
|
+
sendBack({ type: 'createModelPropertiesSuccess' });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
logger$5(`Creating ${Object.keys(propertyDefinitions).length} properties for model "${modelName}" (id: ${_dbId})`);
|
|
617
|
+
const mod = await import('./ModelProperty-DuB5nk6A.js');
|
|
618
|
+
const ModelProperty = mod?.ModelProperty ?? mod?.default;
|
|
619
|
+
if (!ModelProperty) {
|
|
620
|
+
logger$5('ModelProperty not available from dynamic import');
|
|
621
|
+
sendBack({ type: 'createModelPropertiesError', error: new Error('ModelProperty not available') });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const drizzleMod = await import('drizzle-orm');
|
|
625
|
+
const { eq } = drizzleMod;
|
|
626
|
+
const db = BaseDb.getAppDb();
|
|
627
|
+
for (const [propName, propData] of Object.entries(propertyDefinitions)) {
|
|
628
|
+
// Use provided ID or generate a random one
|
|
629
|
+
// IDs should be generated in the import process before creating properties
|
|
630
|
+
const { generateId } = await import('./generateId-BopUwD8F.js');
|
|
631
|
+
const propertyFileId = propData.id || generateId();
|
|
632
|
+
logger$5(`Creating property "${propName}" with fileId "${propertyFileId}"`);
|
|
633
|
+
// Query database to get refModelId if the property has ref/refModelName
|
|
634
|
+
// The property should already be in DB from addModelsToDb
|
|
635
|
+
let refModelId = propData.refModelId;
|
|
636
|
+
if (!refModelId && (propData.ref || propData.refModelName) && db) {
|
|
637
|
+
try {
|
|
638
|
+
const refModelName = propData.refModelName || propData.ref;
|
|
639
|
+
// First try to get refModelId from the property record in the database
|
|
640
|
+
const propertyRecords = await db
|
|
641
|
+
.select()
|
|
642
|
+
.from(properties)
|
|
643
|
+
.where(eq(properties.schemaFileId, propertyFileId))
|
|
644
|
+
.limit(1);
|
|
645
|
+
if (propertyRecords.length > 0 && propertyRecords[0].refModelId) {
|
|
646
|
+
refModelId = propertyRecords[0].refModelId;
|
|
647
|
+
logger$5(`Found refModelId ${refModelId} from database for property "${propName}"`);
|
|
648
|
+
}
|
|
649
|
+
else if (refModelName) {
|
|
650
|
+
// Fallback: query models table directly by name
|
|
651
|
+
const refModelRecords = await db
|
|
652
|
+
.select()
|
|
653
|
+
.from(models)
|
|
654
|
+
.where(eq(models.name, refModelName))
|
|
655
|
+
.limit(1);
|
|
656
|
+
if (refModelRecords.length > 0 && refModelRecords[0].id) {
|
|
657
|
+
refModelId = refModelRecords[0].id;
|
|
658
|
+
logger$5(`Resolved refModelId ${refModelId} from model name "${refModelName}" for property "${propName}"`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
logger$5(`Error fetching refModelId for property "${propName}":`, error);
|
|
664
|
+
// Continue without refModelId - it will be resolved later in _initializeOriginalValues
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Create ModelProperty instance
|
|
668
|
+
// This will load from DB if it exists, or create new instance
|
|
669
|
+
// The property should already be in DB from writeModelToDb
|
|
670
|
+
// Note: propertyFileId is the schemaFileId (string), not the database ID (number)
|
|
671
|
+
// We pass it as _propertyFileId so getById() can find the instance
|
|
672
|
+
await ModelProperty.create({
|
|
673
|
+
name: propName,
|
|
674
|
+
modelName,
|
|
675
|
+
modelId: _dbId,
|
|
676
|
+
dataType: propData.dataType,
|
|
677
|
+
ref: propData.ref,
|
|
678
|
+
refModelName: propData.refModelName,
|
|
679
|
+
refModelId,
|
|
680
|
+
refValueType: propData.refValueType,
|
|
681
|
+
storageType: propData.storageType,
|
|
682
|
+
localStorageDir: propData.localStorageDir,
|
|
683
|
+
filenameSuffix: propData.filenameSuffix,
|
|
684
|
+
required: propData.required,
|
|
685
|
+
_propertyFileId: propertyFileId, // Store schemaFileId for getById() lookups
|
|
686
|
+
}); // Use 'as any' because _propertyFileId is not in TProperty type
|
|
687
|
+
}
|
|
688
|
+
logger$5(`Successfully created all properties for model "${modelName}"`);
|
|
689
|
+
sendBack({ type: 'createModelPropertiesSuccess' });
|
|
690
|
+
};
|
|
691
|
+
_createProperties().catch((error) => {
|
|
692
|
+
logger$5(`Error creating model properties: ${error}`);
|
|
693
|
+
sendBack({
|
|
694
|
+
type: 'createModelPropertiesError',
|
|
695
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
return () => {
|
|
699
|
+
// Cleanup function (optional)
|
|
700
|
+
};
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const logger$4 = debug('seedSdk:write:validateEntity');
|
|
704
|
+
// XState v5 type inference bug: fromPromise types are inverted
|
|
705
|
+
// We use type assertion to work around this
|
|
706
|
+
const validateEntity = fromPromise(async ({ input }) => {
|
|
707
|
+
// Type assertion to fix XState v5 type inference bug
|
|
708
|
+
// Convert through unknown to avoid type overlap error
|
|
709
|
+
const entityInput = input;
|
|
710
|
+
const _validate = async () => {
|
|
711
|
+
try {
|
|
712
|
+
const msg = `[validateEntity] Starting validation for ${entityInput.entityType}`;
|
|
713
|
+
logger$4(msg);
|
|
714
|
+
logger$4(`[validateEntity] Entity data:`, entityInput.entityData);
|
|
715
|
+
let result = { isValid: true, errors: [] };
|
|
716
|
+
if (entityInput.entityType === 'model') {
|
|
717
|
+
const structureMsg = `[validateEntity] Validating model structure`;
|
|
718
|
+
logger$4(structureMsg);
|
|
719
|
+
// Use existing Model validation
|
|
720
|
+
const validationServiceMod = await import('./SchemaValidationService-Dr6nmwPm.js');
|
|
721
|
+
const { SchemaValidationService } = validationServiceMod;
|
|
722
|
+
const validationService = new SchemaValidationService();
|
|
723
|
+
// Validate model structure
|
|
724
|
+
const structureResult = validationService.validateModelStructure(entityInput.entityData);
|
|
725
|
+
const structureResultMsg = `[validateEntity] Structure validation result: isValid=${structureResult.isValid}, errors=${structureResult.errors?.length || 0}`;
|
|
726
|
+
logger$4(structureResultMsg);
|
|
727
|
+
if (!structureResult.isValid) {
|
|
728
|
+
result = {
|
|
729
|
+
isValid: false,
|
|
730
|
+
errors: structureResult.errors,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
// If schema name provided, validate against schema
|
|
735
|
+
if (entityInput.entityData.schemaName) {
|
|
736
|
+
try {
|
|
737
|
+
logger$4(`[validateEntity] Validating model against schema "${entityInput.entityData.schemaName}"`);
|
|
738
|
+
const _mod_5 = await import('./Schema-BYM-GNdE.js');
|
|
739
|
+
const _ns_5 = _mod_5.b;
|
|
740
|
+
const schemaMod = _ns_5;
|
|
741
|
+
const { Schema } = schemaMod;
|
|
742
|
+
const schema = Schema.create(entityInput.entityData.schemaName, {
|
|
743
|
+
waitForReady: false,
|
|
744
|
+
});
|
|
745
|
+
const schemaSnapshot = schema.getService().getSnapshot();
|
|
746
|
+
const schemaStatus = schemaSnapshot.value;
|
|
747
|
+
logger$4(`[validateEntity] Schema status: ${schemaStatus}`);
|
|
748
|
+
if (schemaStatus === 'idle') {
|
|
749
|
+
const schemaContext = schemaSnapshot.context;
|
|
750
|
+
logger$4(`[validateEntity] Running validateModelAgainstSchema`);
|
|
751
|
+
const schemaResult = validationService.validateModelAgainstSchema(schemaContext, entityInput.entityData.modelName, entityInput.entityData);
|
|
752
|
+
logger$4(`[validateEntity] Schema validation result:`, schemaResult);
|
|
753
|
+
if (!schemaResult.isValid) {
|
|
754
|
+
result = {
|
|
755
|
+
isValid: false,
|
|
756
|
+
errors: schemaResult.errors,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
logger$4(`[validateEntity] Schema not in idle state (${schemaStatus}), skipping schema validation`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
logger$4('Error validating model against schema:', error);
|
|
766
|
+
// Continue with structure validation only
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
logger$4(`[validateEntity] No schemaName provided, skipping schema validation`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
else if (entityInput.entityType === 'modelProperty') {
|
|
775
|
+
// Use existing ModelProperty validation
|
|
776
|
+
const validationServiceMod = await import('./SchemaValidationService-Dr6nmwPm.js');
|
|
777
|
+
const { SchemaValidationService } = validationServiceMod;
|
|
778
|
+
const validationService = new SchemaValidationService();
|
|
779
|
+
// Validate property structure
|
|
780
|
+
const structureResult = validationService.validatePropertyStructure(entityInput.entityData);
|
|
781
|
+
if (!structureResult.isValid) {
|
|
782
|
+
result = {
|
|
783
|
+
isValid: false,
|
|
784
|
+
errors: structureResult.errors,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
// If schema name and model name provided, validate against schema
|
|
789
|
+
if (entityInput.entityData._schemaName && entityInput.entityData.modelName) {
|
|
790
|
+
try {
|
|
791
|
+
const _mod_6 = await import('./Schema-BYM-GNdE.js');
|
|
792
|
+
const _ns_6 = _mod_6.b;
|
|
793
|
+
const schemaMod = _ns_6;
|
|
794
|
+
const { Schema } = schemaMod;
|
|
795
|
+
const schema = Schema.create(entityInput.entityData._schemaName, {
|
|
796
|
+
waitForReady: false,
|
|
797
|
+
});
|
|
798
|
+
const schemaSnapshot = schema.getService().getSnapshot();
|
|
799
|
+
const schemaStatus = schemaSnapshot.value;
|
|
800
|
+
if (schemaStatus === 'idle') {
|
|
801
|
+
const schemaContext = schemaSnapshot.context;
|
|
802
|
+
if (schemaContext.models && Object.keys(schemaContext.models).length > 0) {
|
|
803
|
+
const schemaResult = validationService.validateProperty(schemaContext, entityInput.entityData.modelName, entityInput.entityData.name || '', entityInput.entityData);
|
|
804
|
+
if (!schemaResult.isValid) {
|
|
805
|
+
result = {
|
|
806
|
+
isValid: false,
|
|
807
|
+
errors: schemaResult.errors,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
logger$4('Error validating property against schema:', error);
|
|
815
|
+
// Continue with structure validation only
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else if (entityInput.entityType === 'schema') {
|
|
821
|
+
// Schema validation - use existing validation
|
|
822
|
+
const validationServiceMod = await import('./SchemaValidationService-Dr6nmwPm.js');
|
|
823
|
+
const { SchemaValidationService } = validationServiceMod;
|
|
824
|
+
const validationService = new SchemaValidationService();
|
|
825
|
+
const schemaResult = validationService.validateSchema(entityInput.entityData);
|
|
826
|
+
if (!schemaResult.isValid) {
|
|
827
|
+
result = {
|
|
828
|
+
isValid: false,
|
|
829
|
+
errors: schemaResult.errors,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// Return the result - fromPromise will automatically put this in event.output
|
|
834
|
+
const completeMsg = `[validateEntity] Validation complete: isValid=${result.isValid}, errors=${result.errors?.length || 0}`;
|
|
835
|
+
logger$4(completeMsg);
|
|
836
|
+
logger$4(`[validateEntity] Validation complete:`, result);
|
|
837
|
+
return result;
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
const errorMsg = `[validateEntity] Error in validateEntity: ${error instanceof Error ? error.message : String(error)}`;
|
|
841
|
+
logger$4(errorMsg);
|
|
842
|
+
logger$4('Error in validateEntity:', error);
|
|
843
|
+
// Throw error - fromPromise will automatically create error.platform event
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
return await _validate();
|
|
848
|
+
// @ts-ignore - XState v5 type inference bug: fromPromise incorrectly expects output type to match input type
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const logger$3 = debug('seedSdk:write:writeToDatabase');
|
|
852
|
+
const writeToDatabase = fromCallback(({ sendBack, input }) => {
|
|
853
|
+
const _write = async () => {
|
|
854
|
+
try {
|
|
855
|
+
const db = BaseDb.getAppDb();
|
|
856
|
+
if (!db) {
|
|
857
|
+
throw new Error('Database not available');
|
|
858
|
+
}
|
|
859
|
+
let output = input.entityData;
|
|
860
|
+
if (input.entityType === 'model') {
|
|
861
|
+
const writeMsg = `Writing model to database: ${input.entityData.modelName} (schemaId: ${input.entityData.schemaId})`;
|
|
862
|
+
logger$3(writeMsg);
|
|
863
|
+
logger$3(`Writing model to database:`, {
|
|
864
|
+
modelFileId: input.entityId,
|
|
865
|
+
modelName: input.entityData.modelName,
|
|
866
|
+
schemaId: input.entityData.schemaId,
|
|
867
|
+
});
|
|
868
|
+
await writeModelToDb(input.entityId, input.entityData);
|
|
869
|
+
// Get _dbId from database after write
|
|
870
|
+
const db = BaseDb.getAppDb();
|
|
871
|
+
if (db) {
|
|
872
|
+
const modelRecords = await db
|
|
873
|
+
.select({ id: models.id })
|
|
874
|
+
.from(models)
|
|
875
|
+
.where(eq(models.schemaFileId, input.entityId)) // entityId is now the schemaFileId (string)
|
|
876
|
+
.limit(1);
|
|
877
|
+
if (modelRecords.length > 0 && modelRecords[0].id) {
|
|
878
|
+
output = { ...input.entityData, _dbId: modelRecords[0].id }; // Store as _dbId (database integer ID)
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
output = input.entityData;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
output = input.entityData;
|
|
886
|
+
}
|
|
887
|
+
const successMsg = `Successfully wrote model "${input.entityData.modelName}" to database`;
|
|
888
|
+
logger$3(successMsg);
|
|
889
|
+
}
|
|
890
|
+
else if (input.entityType === 'modelProperty') {
|
|
891
|
+
// Use current ModelProperty context when available so a user rename before the
|
|
892
|
+
// initial write completes is not overwritten by the stale requestWrite payload.
|
|
893
|
+
let dataToWrite = input.entityData;
|
|
894
|
+
try {
|
|
895
|
+
const mod = await import('./ModelProperty-DuB5nk6A.js');
|
|
896
|
+
const ModelProperty = mod?.ModelProperty ?? mod?.default;
|
|
897
|
+
if (ModelProperty && typeof ModelProperty.getById === 'function') {
|
|
898
|
+
const instance = ModelProperty.getById(input.entityId);
|
|
899
|
+
if (instance && typeof instance._getSnapshotContext === 'function') {
|
|
900
|
+
const ctx = instance._getSnapshotContext();
|
|
901
|
+
const ctxName = ctx.name ?? input.entityData.name;
|
|
902
|
+
dataToWrite = {
|
|
903
|
+
...input.entityData,
|
|
904
|
+
name: ctxName,
|
|
905
|
+
dataType: ctx.dataType ?? input.entityData.dataType,
|
|
906
|
+
refModelId: ctx.refModelId ?? input.entityData.refModelId,
|
|
907
|
+
refValueType: ctx.refValueType ?? input.entityData.refValueType,
|
|
908
|
+
refModelName: ctx.refModelName ?? input.entityData.refModelName,
|
|
909
|
+
storageType: ctx.storageType ?? input.entityData.storageType,
|
|
910
|
+
localStorageDir: ctx.localStorageDir ?? input.entityData.localStorageDir,
|
|
911
|
+
filenameSuffix: ctx.filenameSuffix ?? input.entityData.filenameSuffix,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
catch (_) {
|
|
917
|
+
// Fall back to input.entityData if instance not available
|
|
918
|
+
}
|
|
919
|
+
await writePropertyToDb(input.entityId, dataToWrite);
|
|
920
|
+
output = input.entityData;
|
|
921
|
+
}
|
|
922
|
+
else if (input.entityType === 'schema') {
|
|
923
|
+
const schemaRecord = await addSchemaToDb(input.entityData, input.entityId, // schemaFileId (string)
|
|
924
|
+
input.entityData.schemaData, input.entityData.isDraft);
|
|
925
|
+
output = { ...input.entityData, _dbId: schemaRecord.id }; // Store as _dbId (database integer ID)
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
throw new Error(`Unknown entity type: ${input.entityType}`);
|
|
929
|
+
}
|
|
930
|
+
// Send success event explicitly - callback actors don't trigger onDone
|
|
931
|
+
sendBack({
|
|
932
|
+
type: 'writeSuccess',
|
|
933
|
+
output,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
logger$3('Error in writeToDatabase:', error);
|
|
938
|
+
sendBack({
|
|
939
|
+
type: 'writeError',
|
|
940
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
_write().catch((error) => {
|
|
945
|
+
logger$3('Error in writeToDatabase promise:', error);
|
|
946
|
+
sendBack({
|
|
947
|
+
type: 'writeError',
|
|
948
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
return () => {
|
|
952
|
+
// Cleanup function (optional)
|
|
953
|
+
};
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const logger$2 = debug('seedSdk:write:writeProcessMachine');
|
|
957
|
+
const writeProcessMachine = setup({
|
|
958
|
+
types: {
|
|
959
|
+
context: {},
|
|
960
|
+
input: {},
|
|
961
|
+
events: {},
|
|
962
|
+
},
|
|
963
|
+
actors: {
|
|
964
|
+
validateEntity,
|
|
965
|
+
writeToDatabase,
|
|
966
|
+
},
|
|
967
|
+
}).createMachine({
|
|
968
|
+
id: 'writeProcess',
|
|
969
|
+
initial: 'idle',
|
|
970
|
+
context: ({ input }) => ({
|
|
971
|
+
entityType: input.entityType,
|
|
972
|
+
entityId: input.entityId,
|
|
973
|
+
entityData: input.entityData || {},
|
|
974
|
+
validationErrors: [],
|
|
975
|
+
writeStatus: 'idle',
|
|
976
|
+
error: null,
|
|
977
|
+
retryCount: 0,
|
|
978
|
+
pendingWrite: null,
|
|
979
|
+
}),
|
|
980
|
+
states: {
|
|
981
|
+
idle: {
|
|
982
|
+
on: {
|
|
983
|
+
startWrite: {
|
|
984
|
+
target: 'validating',
|
|
985
|
+
actions: [
|
|
986
|
+
assign({
|
|
987
|
+
pendingWrite: ({ event }) => ({
|
|
988
|
+
data: event.data,
|
|
989
|
+
timestamp: Date.now(),
|
|
990
|
+
}),
|
|
991
|
+
writeStatus: 'validating',
|
|
992
|
+
}),
|
|
993
|
+
({ event, context }) => {
|
|
994
|
+
logger$2(`[startWrite] Received startWrite for ${context.entityType} "${context.entityId}"`);
|
|
995
|
+
logger$2(`[startWrite] Data:`, event.data);
|
|
996
|
+
},
|
|
997
|
+
],
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
validating: {
|
|
1002
|
+
entry: ({ context }) => {
|
|
1003
|
+
const msg = `[validating] Entering validating state for ${context.entityType} "${context.entityId}"`;
|
|
1004
|
+
logger$2(msg);
|
|
1005
|
+
logger$2(`[validating] Entity data:`, context.pendingWrite?.data || context.entityData);
|
|
1006
|
+
},
|
|
1007
|
+
invoke: {
|
|
1008
|
+
src: 'validateEntity',
|
|
1009
|
+
input: ({ context }) => {
|
|
1010
|
+
const entityData = context.pendingWrite?.data || context.entityData;
|
|
1011
|
+
logger$2(`[validating] Invoking validateEntity with:`, { entityType: context.entityType, entityData });
|
|
1012
|
+
return {
|
|
1013
|
+
entityType: context.entityType,
|
|
1014
|
+
entityData,
|
|
1015
|
+
};
|
|
1016
|
+
},
|
|
1017
|
+
onDone: [
|
|
1018
|
+
{
|
|
1019
|
+
target: 'writing',
|
|
1020
|
+
guard: ({ event }) => {
|
|
1021
|
+
// The output might be the result directly, or it might be wrapped in an event
|
|
1022
|
+
let result;
|
|
1023
|
+
if (event.output && typeof event.output === 'object' && 'isValid' in event.output) {
|
|
1024
|
+
result = event.output;
|
|
1025
|
+
}
|
|
1026
|
+
else if (event.output && typeof event.output === 'object' && 'type' in event.output) {
|
|
1027
|
+
// If it's wrapped in an event, extract the result
|
|
1028
|
+
result = event.output;
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
console.error(`[validating] Unexpected event.output format! Event:`, event);
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
if (!result || typeof result.isValid !== 'boolean') {
|
|
1035
|
+
console.error(`[validating] Invalid result format! Result:`, result);
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
const resultMsg = `[validating] Validation result: isValid=${result.isValid}, errors=${result.errors?.length || 0}`;
|
|
1039
|
+
logger$2(resultMsg);
|
|
1040
|
+
logger$2(`[validating] Validation result:`, result);
|
|
1041
|
+
return result.isValid;
|
|
1042
|
+
},
|
|
1043
|
+
actions: [
|
|
1044
|
+
assign({
|
|
1045
|
+
validationErrors: [],
|
|
1046
|
+
writeStatus: 'writing',
|
|
1047
|
+
}),
|
|
1048
|
+
({ context }) => {
|
|
1049
|
+
const msg = `[validating] Validation passed, transitioning to writing for ${context.entityType} "${context.entityId}"`;
|
|
1050
|
+
logger$2(msg);
|
|
1051
|
+
},
|
|
1052
|
+
],
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
target: 'error',
|
|
1056
|
+
actions: [
|
|
1057
|
+
assign({
|
|
1058
|
+
validationErrors: ({ event }) => {
|
|
1059
|
+
const result = event.output;
|
|
1060
|
+
return result.errors || [];
|
|
1061
|
+
},
|
|
1062
|
+
writeStatus: 'error',
|
|
1063
|
+
error: ({ event }) => {
|
|
1064
|
+
const result = event.output;
|
|
1065
|
+
return new Error(`Validation failed: ${result.errors.map(e => e.message).join(', ')}`);
|
|
1066
|
+
},
|
|
1067
|
+
}),
|
|
1068
|
+
({ context, event }) => {
|
|
1069
|
+
const result = event.output;
|
|
1070
|
+
logger$2(`[validating] Validation failed for ${context.entityType} "${context.entityId}":`, result.errors);
|
|
1071
|
+
},
|
|
1072
|
+
],
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
onError: {
|
|
1076
|
+
target: 'error',
|
|
1077
|
+
actions: [
|
|
1078
|
+
assign({
|
|
1079
|
+
error: ({ event }) => event.error instanceof Error ? event.error : new Error(String(event.error)),
|
|
1080
|
+
writeStatus: 'error',
|
|
1081
|
+
}),
|
|
1082
|
+
({ context, event }) => {
|
|
1083
|
+
const errorMsg = `[validating] Validation error for ${context.entityType} "${context.entityId}": ${event.error}`;
|
|
1084
|
+
logger$2(errorMsg);
|
|
1085
|
+
console.error(errorMsg, event.error); // Always log to console
|
|
1086
|
+
},
|
|
1087
|
+
],
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
on: {
|
|
1091
|
+
reset: 'idle',
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
writing: {
|
|
1095
|
+
invoke: {
|
|
1096
|
+
src: 'writeToDatabase',
|
|
1097
|
+
input: ({ context }) => {
|
|
1098
|
+
const entityData = context.pendingWrite?.data || context.entityData;
|
|
1099
|
+
logger$2(`[writing] Calling writeToDatabase for ${context.entityType} "${context.entityId}"`);
|
|
1100
|
+
logger$2(`[writing] Entity data:`, entityData);
|
|
1101
|
+
return {
|
|
1102
|
+
entityType: context.entityType,
|
|
1103
|
+
entityId: context.entityId,
|
|
1104
|
+
entityData,
|
|
1105
|
+
};
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
on: {
|
|
1109
|
+
writeSuccess: {
|
|
1110
|
+
target: 'success',
|
|
1111
|
+
actions: [
|
|
1112
|
+
assign({
|
|
1113
|
+
writeStatus: 'success',
|
|
1114
|
+
pendingWrite: null,
|
|
1115
|
+
entityData: ({ context, event }) => {
|
|
1116
|
+
// Update entityData with any returned data from write
|
|
1117
|
+
return event.output || context.entityData;
|
|
1118
|
+
},
|
|
1119
|
+
}),
|
|
1120
|
+
({ context }) => {
|
|
1121
|
+
logger$2(`[writing] Write successful for ${context.entityType} "${context.entityId}"`);
|
|
1122
|
+
},
|
|
1123
|
+
],
|
|
1124
|
+
},
|
|
1125
|
+
writeError: {
|
|
1126
|
+
target: 'error',
|
|
1127
|
+
actions: [
|
|
1128
|
+
assign({
|
|
1129
|
+
error: ({ event }) => event.error instanceof Error ? event.error : new Error(String(event.error)),
|
|
1130
|
+
writeStatus: 'error',
|
|
1131
|
+
retryCount: ({ context }) => context.retryCount + 1,
|
|
1132
|
+
}),
|
|
1133
|
+
({ context, event }) => {
|
|
1134
|
+
logger$2(`[writing] Write error for ${context.entityType} "${context.entityId}":`, event.error);
|
|
1135
|
+
},
|
|
1136
|
+
],
|
|
1137
|
+
},
|
|
1138
|
+
reset: 'idle',
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
success: {
|
|
1142
|
+
after: {
|
|
1143
|
+
2000: { target: 'idle' }, // Auto-reset after 2s
|
|
1144
|
+
},
|
|
1145
|
+
on: {
|
|
1146
|
+
reset: 'idle',
|
|
1147
|
+
},
|
|
1148
|
+
entry: assign({
|
|
1149
|
+
writeStatus: 'success',
|
|
1150
|
+
}),
|
|
1151
|
+
},
|
|
1152
|
+
error: {
|
|
1153
|
+
on: {
|
|
1154
|
+
retry: {
|
|
1155
|
+
target: 'validating',
|
|
1156
|
+
guard: ({ context }) => context.retryCount < 3,
|
|
1157
|
+
actions: assign({
|
|
1158
|
+
error: null,
|
|
1159
|
+
}),
|
|
1160
|
+
},
|
|
1161
|
+
revert: {
|
|
1162
|
+
target: 'idle',
|
|
1163
|
+
actions: assign({
|
|
1164
|
+
pendingWrite: null,
|
|
1165
|
+
writeStatus: 'idle',
|
|
1166
|
+
error: null,
|
|
1167
|
+
}),
|
|
1168
|
+
},
|
|
1169
|
+
reset: 'idle',
|
|
1170
|
+
},
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
const logger$1 = debug('seedSdk:model:modelMachine');
|
|
1176
|
+
const modelMachine = setup({
|
|
1177
|
+
types: {
|
|
1178
|
+
context: {},
|
|
1179
|
+
input: {},
|
|
1180
|
+
events: {},
|
|
1181
|
+
},
|
|
1182
|
+
actors: {
|
|
1183
|
+
loadOrCreateModel,
|
|
1184
|
+
validateModel,
|
|
1185
|
+
createModelProperties,
|
|
1186
|
+
writeProcessMachine,
|
|
1187
|
+
},
|
|
1188
|
+
guards: {
|
|
1189
|
+
isModelValid: ({ context }) => {
|
|
1190
|
+
return !context._validationErrors || context._validationErrors.length === 0;
|
|
1191
|
+
},
|
|
1192
|
+
hasValidationErrors: ({ context }) => {
|
|
1193
|
+
return !!context._validationErrors && context._validationErrors.length > 0;
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
actions: {
|
|
1197
|
+
assignValidationErrors: assign(({ context, event }) => {
|
|
1198
|
+
if (event.type === 'validationError') {
|
|
1199
|
+
return {
|
|
1200
|
+
...context,
|
|
1201
|
+
_validationErrors: event.errors,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
return context;
|
|
1205
|
+
}),
|
|
1206
|
+
},
|
|
1207
|
+
}).createMachine({
|
|
1208
|
+
id: 'model',
|
|
1209
|
+
initial: 'loading',
|
|
1210
|
+
context: ({ input }) => ({
|
|
1211
|
+
id: input.id,
|
|
1212
|
+
_dbId: undefined,
|
|
1213
|
+
modelName: input.modelName,
|
|
1214
|
+
schemaName: input.schemaName,
|
|
1215
|
+
_pendingPropertyDefinitions: input._pendingPropertyDefinitions,
|
|
1216
|
+
_idFromSchema: input._idFromSchema,
|
|
1217
|
+
_isDraft: false,
|
|
1218
|
+
_editedProperties: new Set(),
|
|
1219
|
+
_validationErrors: undefined,
|
|
1220
|
+
writeProcess: undefined,
|
|
1221
|
+
_liveQueryPropertyIds: [], // Initialize empty array
|
|
1222
|
+
}),
|
|
1223
|
+
on: {
|
|
1224
|
+
updateContext: [
|
|
1225
|
+
{
|
|
1226
|
+
// Always update context first (no guard, always runs)
|
|
1227
|
+
actions: [
|
|
1228
|
+
assign(({ context, event }) => {
|
|
1229
|
+
const newContext = { ...context };
|
|
1230
|
+
// Check if this is only updating internal fields
|
|
1231
|
+
const onlyInternalFields = Object.keys(event).every((key) => {
|
|
1232
|
+
return key === 'type' || key.startsWith('_');
|
|
1233
|
+
});
|
|
1234
|
+
// Update the context with new values
|
|
1235
|
+
for (let i = 0; i < Object.keys(event).length; i++) {
|
|
1236
|
+
const key = Object.keys(event)[i];
|
|
1237
|
+
if (key === 'type') {
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
const eventValue = event[key];
|
|
1241
|
+
// Ensure arrays are properly copied (create new reference)
|
|
1242
|
+
newContext[key] = Array.isArray(eventValue) ? [...eventValue] : eventValue;
|
|
1243
|
+
}
|
|
1244
|
+
// Compare with original values and set _isEdited flag (only for non-internal updates)
|
|
1245
|
+
// Note: properties are not in context - they're computed from ModelProperty instances
|
|
1246
|
+
// Property changes are tracked via ModelProperty._isEdited flags
|
|
1247
|
+
if (!onlyInternalFields && context._originalValues) {
|
|
1248
|
+
const hasChanges = Object.keys(event).some((key) => {
|
|
1249
|
+
if (key === 'type' || key.startsWith('_') || key === 'properties')
|
|
1250
|
+
return false;
|
|
1251
|
+
const newValue = newContext[key];
|
|
1252
|
+
const oldValue = context._originalValues?.[key];
|
|
1253
|
+
// Deep comparison for objects/arrays
|
|
1254
|
+
if (typeof newValue === 'object' && typeof oldValue === 'object') {
|
|
1255
|
+
return JSON.stringify(newValue) !== JSON.stringify(oldValue);
|
|
1256
|
+
}
|
|
1257
|
+
return newValue !== oldValue;
|
|
1258
|
+
});
|
|
1259
|
+
newContext._isEdited = hasChanges;
|
|
1260
|
+
}
|
|
1261
|
+
// Clear validation errors on context update (will be re-validated if needed)
|
|
1262
|
+
newContext._validationErrors = undefined;
|
|
1263
|
+
return newContext;
|
|
1264
|
+
}),
|
|
1265
|
+
// Verify the context was updated and create property instances if needed
|
|
1266
|
+
({ context, event, self }) => {
|
|
1267
|
+
if (event._liveQueryPropertyIds !== undefined) {
|
|
1268
|
+
const newPropertyIds = event._liveQueryPropertyIds;
|
|
1269
|
+
// Use setTimeout to check after assign has been applied
|
|
1270
|
+
setTimeout(() => {
|
|
1271
|
+
self.getSnapshot();
|
|
1272
|
+
// Create ModelProperty instances for any new property IDs
|
|
1273
|
+
if (Array.isArray(newPropertyIds) && newPropertyIds.length > 0) {
|
|
1274
|
+
// Import and create instances asynchronously (fire-and-forget)
|
|
1275
|
+
import('./ModelProperty-DuB5nk6A.js').then(({ ModelProperty }) => {
|
|
1276
|
+
const createPromises = newPropertyIds.map(async (propertyFileId) => {
|
|
1277
|
+
try {
|
|
1278
|
+
const property = await ModelProperty.createById(propertyFileId);
|
|
1279
|
+
if (property) {
|
|
1280
|
+
logger$1(`[modelMachine] Created/cached ModelProperty instance for propertyFileId "${propertyFileId}" after _liveQueryPropertyIds update`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
logger$1(`[modelMachine] Error creating ModelProperty instance for propertyFileId "${propertyFileId}": ${error}`);
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
Promise.all(createPromises).catch((error) => {
|
|
1288
|
+
logger$1(`[modelMachine] Error creating property instances: ${error}`);
|
|
1289
|
+
});
|
|
1290
|
+
}).catch((error) => {
|
|
1291
|
+
logger$1(`[modelMachine] Error importing ModelProperty: ${error}`);
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}, 0);
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
],
|
|
1298
|
+
// Don't transition - stay in current state, context will be updated
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
// Conditionally trigger validation for non-internal updates
|
|
1302
|
+
guard: ({ event }) => {
|
|
1303
|
+
return !Object.keys(event).every((key) => {
|
|
1304
|
+
return key === 'type' || key.startsWith('_');
|
|
1305
|
+
});
|
|
1306
|
+
},
|
|
1307
|
+
target: '.validating',
|
|
1308
|
+
},
|
|
1309
|
+
],
|
|
1310
|
+
validateModel: {
|
|
1311
|
+
target: '.validating',
|
|
1312
|
+
},
|
|
1313
|
+
validationSuccess: {
|
|
1314
|
+
actions: assign(({ context, event }) => ({
|
|
1315
|
+
...context,
|
|
1316
|
+
_validationErrors: [],
|
|
1317
|
+
})),
|
|
1318
|
+
},
|
|
1319
|
+
validationError: {
|
|
1320
|
+
actions: 'assignValidationErrors',
|
|
1321
|
+
},
|
|
1322
|
+
initializeOriginalValues: {
|
|
1323
|
+
actions: assign(({ context, event }) => ({
|
|
1324
|
+
...context,
|
|
1325
|
+
_originalValues: event.originalValues,
|
|
1326
|
+
_isEdited: event.isEdited ?? false,
|
|
1327
|
+
_validationErrors: undefined,
|
|
1328
|
+
})),
|
|
1329
|
+
},
|
|
1330
|
+
markAsDraft: {
|
|
1331
|
+
actions: assign(({ context, event }) => ({
|
|
1332
|
+
...context,
|
|
1333
|
+
_isEdited: true,
|
|
1334
|
+
_editedProperties: new Set([
|
|
1335
|
+
...(context._editedProperties || []),
|
|
1336
|
+
event.propertyKey,
|
|
1337
|
+
]),
|
|
1338
|
+
})),
|
|
1339
|
+
},
|
|
1340
|
+
clearDraft: {
|
|
1341
|
+
actions: assign(({ context }) => ({
|
|
1342
|
+
...context,
|
|
1343
|
+
_isEdited: false,
|
|
1344
|
+
_editedProperties: new Set(),
|
|
1345
|
+
_originalValues: {
|
|
1346
|
+
// Properties are not stored in context - they're computed from ModelProperty instances
|
|
1347
|
+
// Properties serialization happens separately when initializing original values
|
|
1348
|
+
},
|
|
1349
|
+
})),
|
|
1350
|
+
},
|
|
1351
|
+
destroyStarted: {
|
|
1352
|
+
actions: assign({ _destroyInProgress: true, _destroyError: null }),
|
|
1353
|
+
},
|
|
1354
|
+
destroyDone: {
|
|
1355
|
+
actions: assign({ _destroyInProgress: false }),
|
|
1356
|
+
},
|
|
1357
|
+
destroyError: {
|
|
1358
|
+
actions: assign(({ event }) => ({
|
|
1359
|
+
_destroyInProgress: false,
|
|
1360
|
+
_destroyError: event.error instanceof Error
|
|
1361
|
+
? { message: event.error.message, name: event.error.name }
|
|
1362
|
+
: { message: String(event.error) },
|
|
1363
|
+
})),
|
|
1364
|
+
},
|
|
1365
|
+
clearDestroyError: {
|
|
1366
|
+
actions: assign({ _destroyError: null }),
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
states: {
|
|
1370
|
+
loading: {
|
|
1371
|
+
on: {
|
|
1372
|
+
loadOrCreateModelSuccess: {
|
|
1373
|
+
target: 'idle',
|
|
1374
|
+
actions: assign(({ context, event }) => {
|
|
1375
|
+
const hasPendingProps = !!(context._pendingPropertyDefinitions && Object.keys(context._pendingPropertyDefinitions).length > 0);
|
|
1376
|
+
logger$1(`[loadOrCreateModelSuccess] Preserving _pendingPropertyDefinitions: ${hasPendingProps} (${Object.keys(context._pendingPropertyDefinitions || {}).length} properties)`);
|
|
1377
|
+
// Check if there's a pending model name update (set before this event to avoid validation trigger)
|
|
1378
|
+
const finalModelName = context._pendingModelName || context.modelName;
|
|
1379
|
+
if (context._pendingModelName) {
|
|
1380
|
+
logger$1(`[loadOrCreateModelSuccess] Applying pending model name: "${context.modelName}" -> "${finalModelName}"`);
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
...context,
|
|
1384
|
+
id: event.model.id, // schemaFileId (string)
|
|
1385
|
+
_dbId: event.model._dbId, // Database integer ID
|
|
1386
|
+
_isEdited: event.model._isEdited ?? false, // Load isEdited from database
|
|
1387
|
+
_editedProperties: new Set(),
|
|
1388
|
+
_validationErrors: undefined,
|
|
1389
|
+
// Use final model name (may have been set via _pendingModelName to avoid validation trigger)
|
|
1390
|
+
modelName: finalModelName,
|
|
1391
|
+
// Clear the temporary field
|
|
1392
|
+
_pendingModelName: undefined,
|
|
1393
|
+
// Preserve _pendingPropertyDefinitions if it exists
|
|
1394
|
+
_pendingPropertyDefinitions: context._pendingPropertyDefinitions,
|
|
1395
|
+
// Preserve _liveQueryPropertyIds if provided (set by loadOrCreateModel when loading from DB)
|
|
1396
|
+
_liveQueryPropertyIds: event.model._liveQueryPropertyIds ?? context._liveQueryPropertyIds ?? [],
|
|
1397
|
+
// Preserve conflict detection metadata if provided
|
|
1398
|
+
_loadedAt: event.model._loadedAt,
|
|
1399
|
+
_dbVersion: event.model._dbVersion,
|
|
1400
|
+
_dbUpdatedAt: event.model._dbUpdatedAt,
|
|
1401
|
+
_originalValues: {
|
|
1402
|
+
// Properties are not stored in context - they're computed from ModelProperty instances
|
|
1403
|
+
// Properties will be loaded via liveQuery after model creation
|
|
1404
|
+
},
|
|
1405
|
+
};
|
|
1406
|
+
}),
|
|
1407
|
+
},
|
|
1408
|
+
loadOrCreateModelError: {
|
|
1409
|
+
target: 'error',
|
|
1410
|
+
},
|
|
1411
|
+
},
|
|
1412
|
+
invoke: {
|
|
1413
|
+
src: 'loadOrCreateModel',
|
|
1414
|
+
input: ({ context }) => ({ context }),
|
|
1415
|
+
},
|
|
1416
|
+
},
|
|
1417
|
+
idle: {
|
|
1418
|
+
entry: assign({
|
|
1419
|
+
writeProcess: ({ context, spawn }) => {
|
|
1420
|
+
if (!context.writeProcess && context.id) {
|
|
1421
|
+
logger$1(`[idle entry] Spawning writeProcess for model "${context.modelName}" (${context.id})`);
|
|
1422
|
+
return spawn(writeProcessMachine, {
|
|
1423
|
+
input: {
|
|
1424
|
+
entityType: 'model',
|
|
1425
|
+
entityId: context.id,
|
|
1426
|
+
entityData: {
|
|
1427
|
+
modelName: context.modelName,
|
|
1428
|
+
schemaName: context.schemaName,
|
|
1429
|
+
// Properties are not stored in context - they'll be provided via requestWrite event
|
|
1430
|
+
},
|
|
1431
|
+
},
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
return context.writeProcess;
|
|
1435
|
+
},
|
|
1436
|
+
}),
|
|
1437
|
+
on: {
|
|
1438
|
+
refreshProperties: {
|
|
1439
|
+
actions: ({ self }) => {
|
|
1440
|
+
// This event will be handled by the Model instance's subscription
|
|
1441
|
+
// The Model will call _refreshPropertiesFromDb when it receives this event
|
|
1442
|
+
logger$1(`[idle] refreshProperties event received, will be handled by Model instance`);
|
|
1443
|
+
},
|
|
1444
|
+
},
|
|
1445
|
+
validateModel: {
|
|
1446
|
+
target: 'validating',
|
|
1447
|
+
},
|
|
1448
|
+
reloadFromDb: {
|
|
1449
|
+
target: 'loading',
|
|
1450
|
+
},
|
|
1451
|
+
requestWrite: {
|
|
1452
|
+
actions: ({ context, event }) => {
|
|
1453
|
+
if (context.writeProcess) {
|
|
1454
|
+
logger$1(`[requestWrite] Sending startWrite to writeProcess with data:`, event.data);
|
|
1455
|
+
context.writeProcess.send({
|
|
1456
|
+
type: 'startWrite',
|
|
1457
|
+
data: event.data,
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
logger$1(`[requestWrite] ERROR: writeProcess not available for model "${context.modelName}" (${context.id})`);
|
|
1462
|
+
}
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
writeSuccess: [
|
|
1466
|
+
{
|
|
1467
|
+
guard: ({ context }) => {
|
|
1468
|
+
// If we have pending property definitions, transition to creatingProperties
|
|
1469
|
+
const hasPending = !!(context._pendingPropertyDefinitions && Object.keys(context._pendingPropertyDefinitions).length > 0);
|
|
1470
|
+
logger$1(`[writeSuccess guard] hasPending: ${hasPending}, _pendingPropertyDefinitions keys: ${context._pendingPropertyDefinitions ? Object.keys(context._pendingPropertyDefinitions).length : 0}`);
|
|
1471
|
+
return hasPending;
|
|
1472
|
+
},
|
|
1473
|
+
target: 'creatingProperties',
|
|
1474
|
+
actions: assign(({ context, event }) => {
|
|
1475
|
+
logger$1(`[writeSuccess] Transitioning to creatingProperties for model "${context.modelName}"`);
|
|
1476
|
+
// Update _dbId from write output if available (writeToDatabase returns _dbId, not id)
|
|
1477
|
+
const newContext = { ...context };
|
|
1478
|
+
const dbId = event.output?._dbId ?? event.output?.id;
|
|
1479
|
+
if (dbId != null) {
|
|
1480
|
+
newContext._dbId = dbId;
|
|
1481
|
+
logger$1(`[writeSuccess] Updated _dbId to ${dbId}`);
|
|
1482
|
+
}
|
|
1483
|
+
return newContext;
|
|
1484
|
+
}),
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
target: 'idle',
|
|
1488
|
+
actions: assign(({ context, event }) => {
|
|
1489
|
+
// Update _dbId from write output if available (writeToDatabase returns _dbId, not id)
|
|
1490
|
+
const newContext = { ...context };
|
|
1491
|
+
const dbId = event.output?._dbId ?? event.output?.id;
|
|
1492
|
+
if (dbId != null) {
|
|
1493
|
+
newContext._dbId = dbId;
|
|
1494
|
+
}
|
|
1495
|
+
return newContext;
|
|
1496
|
+
}),
|
|
1497
|
+
},
|
|
1498
|
+
],
|
|
1499
|
+
},
|
|
1500
|
+
always: {
|
|
1501
|
+
guard: 'hasValidationErrors',
|
|
1502
|
+
target: 'validating',
|
|
1503
|
+
},
|
|
1504
|
+
},
|
|
1505
|
+
validating: {
|
|
1506
|
+
on: {
|
|
1507
|
+
validationSuccess: {
|
|
1508
|
+
target: 'idle',
|
|
1509
|
+
actions: assign(({ context, event }) => ({
|
|
1510
|
+
...context,
|
|
1511
|
+
_validationErrors: [],
|
|
1512
|
+
})),
|
|
1513
|
+
},
|
|
1514
|
+
validationError: {
|
|
1515
|
+
target: 'idle',
|
|
1516
|
+
actions: 'assignValidationErrors',
|
|
1517
|
+
},
|
|
1518
|
+
},
|
|
1519
|
+
invoke: {
|
|
1520
|
+
src: 'validateModel',
|
|
1521
|
+
input: ({ context }) => ({ context }),
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
creatingProperties: {
|
|
1525
|
+
entry: ({ context }) => {
|
|
1526
|
+
logger$1(`[creatingProperties entry] Starting property creation for model "${context.modelName}" with ${Object.keys(context._pendingPropertyDefinitions || {}).length} properties`);
|
|
1527
|
+
},
|
|
1528
|
+
invoke: {
|
|
1529
|
+
src: 'createModelProperties',
|
|
1530
|
+
input: ({ context }) => {
|
|
1531
|
+
logger$1(`[creatingProperties invoke] Invoking createModelProperties with ${Object.keys(context._pendingPropertyDefinitions || {}).length} property definitions`);
|
|
1532
|
+
return {
|
|
1533
|
+
context,
|
|
1534
|
+
propertyDefinitions: context._pendingPropertyDefinitions || {},
|
|
1535
|
+
};
|
|
1536
|
+
},
|
|
1537
|
+
},
|
|
1538
|
+
on: {
|
|
1539
|
+
createModelPropertiesSuccess: {
|
|
1540
|
+
target: 'idle',
|
|
1541
|
+
actions: [
|
|
1542
|
+
assign(({ context }) => {
|
|
1543
|
+
// Only clear _pendingPropertyDefinitions, preserve everything else including _liveQueryPropertyIds
|
|
1544
|
+
return {
|
|
1545
|
+
...context,
|
|
1546
|
+
_pendingPropertyDefinitions: undefined,
|
|
1547
|
+
};
|
|
1548
|
+
}),
|
|
1549
|
+
({ context, self }) => {
|
|
1550
|
+
// Trigger property refresh after properties are created
|
|
1551
|
+
// This ensures _liveQueryPropertyIds is updated in Node.js where liveQuery isn't available
|
|
1552
|
+
setTimeout(() => {
|
|
1553
|
+
self.send({ type: 'refreshProperties' });
|
|
1554
|
+
}, 100); // Small delay to ensure properties are written to DB
|
|
1555
|
+
},
|
|
1556
|
+
],
|
|
1557
|
+
},
|
|
1558
|
+
createModelPropertiesError: {
|
|
1559
|
+
target: 'error',
|
|
1560
|
+
actions: ({ event }) => {
|
|
1561
|
+
logger$1('Error creating model properties:', event.error);
|
|
1562
|
+
},
|
|
1563
|
+
},
|
|
1564
|
+
},
|
|
1565
|
+
},
|
|
1566
|
+
error: {},
|
|
1567
|
+
},
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Error thrown when a conflict is detected between actor context and database
|
|
1572
|
+
*/
|
|
1573
|
+
class ConflictError extends Error {
|
|
1574
|
+
constructor(message, conflict) {
|
|
1575
|
+
super(message);
|
|
1576
|
+
this.name = 'ConflictError';
|
|
1577
|
+
this.conflict = conflict;
|
|
1578
|
+
Object.setPrototypeOf(this, ConflictError.prototype);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Set up liveQuery subscription for an entity
|
|
1584
|
+
* This enables cross-instance synchronization (e.g., changes in other tabs/windows)
|
|
1585
|
+
*
|
|
1586
|
+
* @param instance - Entity instance
|
|
1587
|
+
* @param config - LiveQuery configuration
|
|
1588
|
+
*/
|
|
1589
|
+
function setupEntityLiveQuery(instance, config) {
|
|
1590
|
+
const isBrowser = typeof window !== 'undefined';
|
|
1591
|
+
const logger = debug(config.loggerName);
|
|
1592
|
+
// Use a closure variable to track setup state per instance
|
|
1593
|
+
const setupState = { subscriptionSetUp: false };
|
|
1594
|
+
const setupLiveQuery = async (entityId) => {
|
|
1595
|
+
if (setupState.subscriptionSetUp) {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
setupState.subscriptionSetUp = true;
|
|
1599
|
+
logger(`Setting up liveQuery for entity ID: ${entityId}`);
|
|
1600
|
+
try {
|
|
1601
|
+
// Query initial data if provided (for Node.js compatibility)
|
|
1602
|
+
if (config.queryInitialData) {
|
|
1603
|
+
const initialRows = await config.queryInitialData(entityId);
|
|
1604
|
+
const initialIds = config.extractEntityIds(initialRows);
|
|
1605
|
+
if (initialIds.length > 0) {
|
|
1606
|
+
logger(`Initial query returned ${initialIds.length} entities`);
|
|
1607
|
+
// Create child instances if provided
|
|
1608
|
+
if (config.createChildInstances) {
|
|
1609
|
+
await config.createChildInstances(initialIds);
|
|
1610
|
+
}
|
|
1611
|
+
// Update context with initial IDs
|
|
1612
|
+
config.updateContext(instance, initialIds);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// Only set up liveQuery subscription in browser environment
|
|
1616
|
+
if (isBrowser) {
|
|
1617
|
+
// buildQuery can be async to allow dynamic imports
|
|
1618
|
+
const queryResult = config.buildQuery(entityId);
|
|
1619
|
+
const query$ = queryResult instanceof Promise ? await queryResult : queryResult;
|
|
1620
|
+
const instanceState = config.instanceState.get(instance);
|
|
1621
|
+
if (!instanceState) {
|
|
1622
|
+
logger('Instance state not found');
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
// Subscribe to liveQuery updates
|
|
1626
|
+
const subscription = query$.subscribe({
|
|
1627
|
+
next: async (rows) => {
|
|
1628
|
+
// Check if instance state still exists (hasn't been cleaned up)
|
|
1629
|
+
const currentInstanceState = config.instanceState.get(instance);
|
|
1630
|
+
if (!currentInstanceState) {
|
|
1631
|
+
logger('Instance state was cleaned up, skipping update');
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
logger(`Query returned ${rows.length} rows`);
|
|
1635
|
+
const ids = config.extractEntityIds(rows);
|
|
1636
|
+
// Create child instances if provided (before updating context)
|
|
1637
|
+
if (config.createChildInstances && ids.length > 0) {
|
|
1638
|
+
await config.createChildInstances(ids);
|
|
1639
|
+
}
|
|
1640
|
+
// Update context with new IDs
|
|
1641
|
+
config.updateContext(instance, ids);
|
|
1642
|
+
},
|
|
1643
|
+
error: (error) => {
|
|
1644
|
+
logger(`LiveQuery error: ${error}`);
|
|
1645
|
+
},
|
|
1646
|
+
});
|
|
1647
|
+
instanceState.liveQuerySubscription = subscription;
|
|
1648
|
+
logger(`LiveQuery subscription set up for entity ID: ${entityId}`);
|
|
1649
|
+
}
|
|
1650
|
+
else {
|
|
1651
|
+
logger('Skipping liveQuery subscription in Node.js environment');
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
catch (error) {
|
|
1655
|
+
logger(`Error setting up subscription: ${error}`);
|
|
1656
|
+
setupState.subscriptionSetUp = false; // Reset on error so we can retry
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1659
|
+
// Set up liveQuery subscription as soon as we have entity ID
|
|
1660
|
+
const setupSubscription = instance.getService().subscribe(async (snapshot) => {
|
|
1661
|
+
// Check if entity is ready
|
|
1662
|
+
if (config.isReady && !config.isReady(instance)) {
|
|
1663
|
+
if (config.waitForReady) {
|
|
1664
|
+
await config.waitForReady(instance);
|
|
1665
|
+
}
|
|
1666
|
+
else {
|
|
1667
|
+
return; // Not ready yet, will retry on next snapshot
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
// Get entity ID
|
|
1671
|
+
const entityId = await config.getEntityId(instance);
|
|
1672
|
+
if (!entityId) {
|
|
1673
|
+
return; // Need entity ID to proceed
|
|
1674
|
+
}
|
|
1675
|
+
// Once we have entity ID, set up the liveQuery subscription (only once)
|
|
1676
|
+
if (!setupState.subscriptionSetUp) {
|
|
1677
|
+
await setupLiveQuery(entityId);
|
|
1678
|
+
if (setupState.subscriptionSetUp) {
|
|
1679
|
+
setupSubscription.unsubscribe();
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
// Also check current state immediately in case entity ID is already available
|
|
1684
|
+
instance.getService().getSnapshot();
|
|
1685
|
+
if (config.isReady && !config.isReady(instance)) {
|
|
1686
|
+
// Not ready yet, will be handled by subscription
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
config.getEntityId(instance).then((entityId) => {
|
|
1690
|
+
if (entityId && !setupState.subscriptionSetUp) {
|
|
1691
|
+
setupLiveQuery(entityId).catch((error) => {
|
|
1692
|
+
logger(`Error in immediate setup: ${error}`);
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
}).catch((error) => {
|
|
1696
|
+
logger(`Error getting entity ID: ${error}`);
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Returns model rows (id, name, schemaFileId, isEdited) for building Model instances.
|
|
1702
|
+
* When schemaIdentifier is provided, only returns models for that schema (join models -> model_schemas -> schemas).
|
|
1703
|
+
* schemaIdentifier can be either schema name or schema file ID.
|
|
1704
|
+
*/
|
|
1705
|
+
const getModelsData = async (schemaIdentifier) => {
|
|
1706
|
+
const appDb = BaseDb.getAppDb();
|
|
1707
|
+
if (!appDb) {
|
|
1708
|
+
return [];
|
|
1709
|
+
}
|
|
1710
|
+
if (schemaIdentifier !== undefined && schemaIdentifier !== '') {
|
|
1711
|
+
const runQuery = () => appDb
|
|
1712
|
+
.select({
|
|
1713
|
+
id: models.id,
|
|
1714
|
+
name: models.name,
|
|
1715
|
+
schemaFileId: models.schemaFileId,
|
|
1716
|
+
isEdited: models.isEdited,
|
|
1717
|
+
})
|
|
1718
|
+
.from(models)
|
|
1719
|
+
.innerJoin(modelSchemas, eq(models.id, modelSchemas.modelId))
|
|
1720
|
+
.innerJoin(schemas, eq(modelSchemas.schemaId, schemas.id))
|
|
1721
|
+
.where(or(eq(schemas.name, schemaIdentifier), eq(schemas.schemaFileId, schemaIdentifier)));
|
|
1722
|
+
let rows = await runQuery();
|
|
1723
|
+
// Retry when we get 0 rows (read-after-write consistency; SQLocal/async may not see the write immediately).
|
|
1724
|
+
for (let i = 0; i < 4 && rows.length === 0; i++) {
|
|
1725
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1726
|
+
rows = await runQuery();
|
|
1727
|
+
}
|
|
1728
|
+
return rows;
|
|
1729
|
+
}
|
|
1730
|
+
const rows = await appDb
|
|
1731
|
+
.select({
|
|
1732
|
+
id: models.id,
|
|
1733
|
+
name: models.name,
|
|
1734
|
+
schemaFileId: models.schemaFileId,
|
|
1735
|
+
isEdited: models.isEdited,
|
|
1736
|
+
})
|
|
1737
|
+
.from(models);
|
|
1738
|
+
return rows;
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1741
|
+
const logger = debug('seedSdk:model:Model');
|
|
1742
|
+
const saveDraftLogger = debug('seedSdk:model:saveDraftToDb');
|
|
1743
|
+
// Lazy import cache for ModelProperty to avoid circular dependency
|
|
1744
|
+
// Eagerly start loading to minimize delay on first access
|
|
1745
|
+
let ModelPropertyClass = null;
|
|
1746
|
+
const modelPropertyImportPromise = import('./ModelProperty-DuB5nk6A.js')
|
|
1747
|
+
.then(module => {
|
|
1748
|
+
ModelPropertyClass = module.ModelProperty;
|
|
1749
|
+
return ModelPropertyClass;
|
|
1750
|
+
})
|
|
1751
|
+
.catch(() => {
|
|
1752
|
+
// If import fails, ModelPropertyClass remains null
|
|
1753
|
+
return null;
|
|
1754
|
+
});
|
|
1755
|
+
function getModelProperty() {
|
|
1756
|
+
// Return cached class if available (synchronous access)
|
|
1757
|
+
// Note: On first access before import completes, this will return null
|
|
1758
|
+
// and the getter will return empty array, which matches the original behavior
|
|
1759
|
+
return ModelPropertyClass;
|
|
1760
|
+
}
|
|
1761
|
+
// Lazy import cache for Schema to avoid circular dependency
|
|
1762
|
+
let SchemaClass = null;
|
|
1763
|
+
const schemaImportPromise = (async () => { const _mod_7 = await import('./Schema-BYM-GNdE.js'); return _mod_7.b; })()
|
|
1764
|
+
.then(module => {
|
|
1765
|
+
SchemaClass = module.Schema;
|
|
1766
|
+
return SchemaClass;
|
|
1767
|
+
})
|
|
1768
|
+
.catch(() => {
|
|
1769
|
+
// If import fails, SchemaClass remains null
|
|
1770
|
+
return null;
|
|
1771
|
+
});
|
|
1772
|
+
// WeakMap to store mutable state per Model instance
|
|
1773
|
+
// This avoids issues with read-only properties when instances are frozen by Immer
|
|
1774
|
+
const modelInstanceState = new WeakMap();
|
|
1775
|
+
// Define tracked properties for the Proxy
|
|
1776
|
+
// These properties will be read from/written to the actor context
|
|
1777
|
+
const TRACKED_PROPERTIES = [
|
|
1778
|
+
'id', // schemaFileId (string) - public ID
|
|
1779
|
+
'modelName',
|
|
1780
|
+
'schemaName',
|
|
1781
|
+
];
|
|
1782
|
+
class Model {
|
|
1783
|
+
static trackPendingWrite(modelFileId, schemaId) {
|
|
1784
|
+
this.pendingWrites.set(modelFileId, {
|
|
1785
|
+
modelFileId,
|
|
1786
|
+
schemaId,
|
|
1787
|
+
status: 'pending',
|
|
1788
|
+
timestamp: Date.now(),
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
static getPendingModelIds(schemaId) {
|
|
1792
|
+
return Array.from(this.pendingWrites.entries())
|
|
1793
|
+
.filter(([_, write]) => write.schemaId === schemaId && write.status !== 'error')
|
|
1794
|
+
.map(([modelFileId]) => modelFileId);
|
|
1795
|
+
}
|
|
1796
|
+
// 'name' is available as an alias for 'modelName' via the proxy
|
|
1797
|
+
constructor(modelName, schemaName, id, // schemaFileId (string) - public ID
|
|
1798
|
+
initialContext, idFromSchema // true when id was provided via options (schema model), not generated
|
|
1799
|
+
) {
|
|
1800
|
+
const serviceInput = {
|
|
1801
|
+
modelName,
|
|
1802
|
+
schemaName,
|
|
1803
|
+
...(id ? { id } : {}),
|
|
1804
|
+
...(initialContext?._pendingPropertyDefinitions ? { _pendingPropertyDefinitions: initialContext._pendingPropertyDefinitions } : {}),
|
|
1805
|
+
_idFromSchema: !!idFromSchema,
|
|
1806
|
+
};
|
|
1807
|
+
this._service = createActor(modelMachine, {
|
|
1808
|
+
input: serviceInput,
|
|
1809
|
+
});
|
|
1810
|
+
this._service.start();
|
|
1811
|
+
// Initialize instance state in WeakMap
|
|
1812
|
+
modelInstanceState.set(this, {
|
|
1813
|
+
liveQuerySubscription: null,
|
|
1814
|
+
});
|
|
1815
|
+
// Set up liveQuery subscription for cross-instance property updates
|
|
1816
|
+
this._setupLiveQuerySubscription();
|
|
1817
|
+
// Subscribe to refresh properties after they're created (for Node.js where liveQuery isn't available)
|
|
1818
|
+
let previousState;
|
|
1819
|
+
this._service.subscribe((snapshot) => {
|
|
1820
|
+
// Check if we just transitioned to idle from creatingProperties state
|
|
1821
|
+
if (snapshot.value === 'idle' && previousState === 'creatingProperties') {
|
|
1822
|
+
// Small delay to ensure properties are written to DB
|
|
1823
|
+
setTimeout(() => {
|
|
1824
|
+
this._refreshPropertiesFromDb().catch((error) => {
|
|
1825
|
+
logger(`[Model constructor] Error refreshing properties: ${error}`);
|
|
1826
|
+
});
|
|
1827
|
+
}, 200);
|
|
1828
|
+
}
|
|
1829
|
+
previousState = snapshot.value;
|
|
1830
|
+
});
|
|
1831
|
+
// Note: Property getters/setters are now handled by the Proxy in create()
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Find a unique model name by checking for duplicates (case-insensitive) in the cache
|
|
1835
|
+
* If duplicates are found, appends an incrementing number to make it unique
|
|
1836
|
+
*
|
|
1837
|
+
* @param modelName - The desired model name
|
|
1838
|
+
* @param schemaName - The schema name
|
|
1839
|
+
* @param skipAllChecks - If true, skip all duplicate checks and return original name (used when creating schema models to preserve original names)
|
|
1840
|
+
* @returns A unique model name
|
|
1841
|
+
*/
|
|
1842
|
+
static findUniqueModelName(modelName, schemaName, skipAllChecks = false) {
|
|
1843
|
+
// If skipAllChecks is true (schema models), preserve original name
|
|
1844
|
+
if (skipAllChecks) {
|
|
1845
|
+
return modelName;
|
|
1846
|
+
}
|
|
1847
|
+
const lowerModelName = modelName.toLowerCase();
|
|
1848
|
+
// Check all cached models for this schema
|
|
1849
|
+
const existingNames = new Set();
|
|
1850
|
+
const existingNumbers = new Set();
|
|
1851
|
+
// Check name-based cache
|
|
1852
|
+
for (const [nameKey, modelFileId] of this.instanceCacheByName.entries()) {
|
|
1853
|
+
const [cachedSchemaName, cachedModelName] = nameKey.split(':', 2);
|
|
1854
|
+
if (cachedSchemaName === schemaName && cachedModelName) {
|
|
1855
|
+
const lowerCachedName = cachedModelName.toLowerCase();
|
|
1856
|
+
existingNames.add(lowerCachedName);
|
|
1857
|
+
// If it matches the base name (case-insensitive), check if it has a number suffix
|
|
1858
|
+
if (lowerCachedName === lowerModelName) {
|
|
1859
|
+
existingNumbers.add(0); // Base name exists
|
|
1860
|
+
}
|
|
1861
|
+
else if (lowerCachedName.startsWith(lowerModelName + ' ')) {
|
|
1862
|
+
// Check if it's the base name followed by a space and a number
|
|
1863
|
+
const suffix = lowerCachedName.slice(lowerModelName.length + 1);
|
|
1864
|
+
const number = parseInt(suffix, 10);
|
|
1865
|
+
if (!isNaN(number) && suffix === number.toString()) {
|
|
1866
|
+
existingNumbers.add(number);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
// Check legacy cache
|
|
1872
|
+
for (const [nameKey] of this.instanceCache.keys()) {
|
|
1873
|
+
const [cachedSchemaName, cachedModelName] = nameKey.split(':', 2);
|
|
1874
|
+
if (cachedSchemaName === schemaName && cachedModelName) {
|
|
1875
|
+
const lowerCachedName = cachedModelName.toLowerCase();
|
|
1876
|
+
existingNames.add(lowerCachedName);
|
|
1877
|
+
// If it matches the base name (case-insensitive), check if it has a number suffix
|
|
1878
|
+
if (lowerCachedName === lowerModelName) {
|
|
1879
|
+
existingNumbers.add(0); // Base name exists
|
|
1880
|
+
}
|
|
1881
|
+
else if (lowerCachedName.startsWith(lowerModelName + ' ')) {
|
|
1882
|
+
// Check if it's the base name followed by a space and a number
|
|
1883
|
+
const suffix = lowerCachedName.slice(lowerModelName.length + 1);
|
|
1884
|
+
const number = parseInt(suffix, 10);
|
|
1885
|
+
if (!isNaN(number) && suffix === number.toString()) {
|
|
1886
|
+
existingNumbers.add(number);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
// Also check schema context models (case-insensitive)
|
|
1892
|
+
// This ensures runtime-created models are renamed if they conflict with schema-defined models
|
|
1893
|
+
try {
|
|
1894
|
+
// Use lazy-loaded Schema class to avoid circular dependency
|
|
1895
|
+
if (SchemaClass) {
|
|
1896
|
+
const schema = SchemaClass.create(schemaName);
|
|
1897
|
+
const schemaContext = schema.getService().getSnapshot().context;
|
|
1898
|
+
if (schemaContext.models) {
|
|
1899
|
+
for (const schemaModelName of Object.keys(schemaContext.models)) {
|
|
1900
|
+
const lowerSchemaModelName = schemaModelName.toLowerCase();
|
|
1901
|
+
// If it matches the base name (case-insensitive), check if it has a number suffix
|
|
1902
|
+
if (lowerSchemaModelName === lowerModelName) {
|
|
1903
|
+
existingNumbers.add(0); // Base name exists in schema
|
|
1904
|
+
}
|
|
1905
|
+
else if (lowerSchemaModelName.startsWith(lowerModelName + ' ')) {
|
|
1906
|
+
// Check if it's the base name followed by a space and a number
|
|
1907
|
+
const suffix = lowerSchemaModelName.slice(lowerModelName.length + 1);
|
|
1908
|
+
const number = parseInt(suffix, 10);
|
|
1909
|
+
if (!isNaN(number) && suffix === number.toString()) {
|
|
1910
|
+
existingNumbers.add(number);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
catch (error) {
|
|
1918
|
+
// If schema check fails, continue with cache-only check
|
|
1919
|
+
// This is a best-effort check and shouldn't block model creation
|
|
1920
|
+
}
|
|
1921
|
+
// If no duplicates found (no base name match and no numbered variants), return original name
|
|
1922
|
+
if (existingNumbers.size === 0) {
|
|
1923
|
+
return modelName;
|
|
1924
|
+
}
|
|
1925
|
+
// Find the next available number
|
|
1926
|
+
// Start from 1 if base name exists, otherwise check if we can use 1
|
|
1927
|
+
let nextNumber = existingNumbers.has(0) ? 1 : 1;
|
|
1928
|
+
while (existingNumbers.has(nextNumber)) {
|
|
1929
|
+
nextNumber++;
|
|
1930
|
+
}
|
|
1931
|
+
// Return the name with the number appended
|
|
1932
|
+
return `${modelName} ${nextNumber}`;
|
|
1933
|
+
}
|
|
1934
|
+
static create(modelName, schemaNameOrSchema, // Schema type - using any to avoid circular dependency
|
|
1935
|
+
options) {
|
|
1936
|
+
if (!modelName) {
|
|
1937
|
+
throw new Error('Model name is required');
|
|
1938
|
+
}
|
|
1939
|
+
const waitForReady = options?.waitForReady !== false;
|
|
1940
|
+
const readyTimeout = options?.readyTimeout ?? 5000;
|
|
1941
|
+
// Extract schema name and instance
|
|
1942
|
+
let schemaName;
|
|
1943
|
+
let schemaInstance; // Using any to avoid circular dependency with Schema
|
|
1944
|
+
if (typeof schemaNameOrSchema === 'string') {
|
|
1945
|
+
schemaName = schemaNameOrSchema;
|
|
1946
|
+
}
|
|
1947
|
+
else {
|
|
1948
|
+
schemaInstance = schemaNameOrSchema;
|
|
1949
|
+
schemaName = schemaInstance.schemaName;
|
|
1950
|
+
}
|
|
1951
|
+
if (!schemaName) {
|
|
1952
|
+
throw new Error('Schema name is required');
|
|
1953
|
+
}
|
|
1954
|
+
// Support both modelFileId (documented) and id (backward compatibility)
|
|
1955
|
+
const id = options?.modelFileId || options?.id; // schemaFileId (string) - public ID
|
|
1956
|
+
const registerWithSchema = options?.registerWithSchema !== false && schemaInstance !== undefined;
|
|
1957
|
+
// Step 1: Check ID-based cache first (if ID provided)
|
|
1958
|
+
if (id && this.instanceCacheById.has(id)) {
|
|
1959
|
+
const { instance, refCount } = this.instanceCacheById.get(id);
|
|
1960
|
+
this.instanceCacheById.set(id, {
|
|
1961
|
+
instance,
|
|
1962
|
+
refCount: refCount + 1,
|
|
1963
|
+
});
|
|
1964
|
+
logger(`Model.create: Found instance in ID cache for "${modelName}" (ID: ${id})`);
|
|
1965
|
+
if (!waitForReady)
|
|
1966
|
+
return instance;
|
|
1967
|
+
return waitForEntityIdle(instance, { timeout: readyTimeout }).then(() => instance);
|
|
1968
|
+
}
|
|
1969
|
+
// Step 2: Check for duplicate names and generate unique name if needed (before checking cache)
|
|
1970
|
+
// This allows us to create new models with renamed versions when duplicates exist
|
|
1971
|
+
// Skip all duplicate checks if modelFileId is provided (schema models should preserve original names)
|
|
1972
|
+
const skipAllChecks = !!id; // If id (modelFileId) is provided, this is a schema model - skip schema check to preserve name
|
|
1973
|
+
const uniqueModelName = this.findUniqueModelName(modelName, schemaName, skipAllChecks);
|
|
1974
|
+
// Step 3: Check name-based index with ORIGINAL name first (before using unique name)
|
|
1975
|
+
// Only return existing instance if the unique name matches the original (no rename needed)
|
|
1976
|
+
// If a rename is needed, we'll create a new model with the unique name
|
|
1977
|
+
let resolvedId = id;
|
|
1978
|
+
const originalNameKey = `${schemaName}:${modelName}`;
|
|
1979
|
+
// Step 3: When we have no id (runtime create), do not return from name cache. Each create('My Model')
|
|
1980
|
+
// must get a new instance with an incremented name (My Model, My Model 1, ...). If the only cached
|
|
1981
|
+
// instance with this name is the schema model (_idFromSchema), ensure we fall through to create new.
|
|
1982
|
+
if (!resolvedId) {
|
|
1983
|
+
const cachedId = this.instanceCacheByName.get(originalNameKey);
|
|
1984
|
+
if (cachedId && this.instanceCacheById.has(cachedId)) {
|
|
1985
|
+
const { instance, refCount } = this.instanceCacheById.get(cachedId);
|
|
1986
|
+
const ctx = instance._getSnapshotContext();
|
|
1987
|
+
if (ctx._idFromSchema) {
|
|
1988
|
+
resolvedId = undefined;
|
|
1989
|
+
}
|
|
1990
|
+
// Never return by name when !id (runtime create); fall through to create new with unique name
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
// Step 4: Check legacy cache with original name. Skip when !id (runtime create) so duplicate
|
|
1994
|
+
// create('My Model') calls get new instances with incremented names.
|
|
1995
|
+
if (id && uniqueModelName === modelName) {
|
|
1996
|
+
const legacyKey = `${schemaName}:${modelName}`;
|
|
1997
|
+
if (this.instanceCache.has(legacyKey)) {
|
|
1998
|
+
const { instance, refCount } = this.instanceCache.get(legacyKey);
|
|
1999
|
+
const ctx = instance._getSnapshotContext();
|
|
2000
|
+
if (!ctx._idFromSchema) {
|
|
2001
|
+
this.instanceCache.set(legacyKey, {
|
|
2002
|
+
instance,
|
|
2003
|
+
refCount: refCount + 1,
|
|
2004
|
+
});
|
|
2005
|
+
logger(`Model.create: Found instance in legacy cache for "${modelName}"`);
|
|
2006
|
+
const existingId = instance._getSnapshotContext().id;
|
|
2007
|
+
if (existingId) {
|
|
2008
|
+
this.instanceCacheById.set(existingId, { instance, refCount: refCount + 1 });
|
|
2009
|
+
this.instanceCacheByName.set(legacyKey, existingId);
|
|
2010
|
+
}
|
|
2011
|
+
if (!waitForReady)
|
|
2012
|
+
return instance;
|
|
2013
|
+
return waitForEntityIdle(instance, { timeout: readyTimeout }).then(() => instance);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
// Step 5: When a rename is needed (uniqueModelName !== modelName), do NOT return a cached
|
|
2018
|
+
// instance. The only cached instance with the original name at this point is the
|
|
2019
|
+
// schema-defined model (created with modelFileId); returning it would give the user the
|
|
2020
|
+
// schema model instead of a new runtime model with the unique name (e.g. "New model 1").
|
|
2021
|
+
if (uniqueModelName !== modelName) {
|
|
2022
|
+
logger(`Model.create: Found duplicate name "${modelName}" in schema "${schemaName}", using unique name "${uniqueModelName}"`);
|
|
2023
|
+
}
|
|
2024
|
+
// Step 6a: Generate ID now when needed so we can use a placeholder cache key for runtime creates
|
|
2025
|
+
if (!resolvedId) {
|
|
2026
|
+
resolvedId = generateId();
|
|
2027
|
+
logger(`Model.create: Generated new id (schemaFileId) "${resolvedId}" for model "${modelName}"`);
|
|
2028
|
+
}
|
|
2029
|
+
// Create name-based cache key. When no id (runtime create), use a placeholder so we don't
|
|
2030
|
+
// overwrite the schema model's entry (e.g. "New model"); loadOrCreateModel will replace it with the final name.
|
|
2031
|
+
const nameKey = id
|
|
2032
|
+
? `${schemaName}:${uniqueModelName}`
|
|
2033
|
+
: `${schemaName}:__pending__${resolvedId}`;
|
|
2034
|
+
// Step 6: Check legacy cache with unique name (backward compatibility during migration)
|
|
2035
|
+
// This is a fallback in case an instance was cached with a unique name
|
|
2036
|
+
if (this.instanceCache.has(nameKey)) {
|
|
2037
|
+
const { instance, refCount } = this.instanceCache.get(nameKey);
|
|
2038
|
+
this.instanceCache.set(nameKey, {
|
|
2039
|
+
instance,
|
|
2040
|
+
refCount: refCount + 1,
|
|
2041
|
+
});
|
|
2042
|
+
logger(`Model.create: Found instance in legacy cache for "${modelName}"`);
|
|
2043
|
+
// Migrate to new cache structure
|
|
2044
|
+
const context = instance._getSnapshotContext();
|
|
2045
|
+
const existingId = context.id; // id is now the schemaFileId (string)
|
|
2046
|
+
if (existingId) {
|
|
2047
|
+
this.instanceCacheById.set(existingId, { instance, refCount: refCount + 1 });
|
|
2048
|
+
this.instanceCacheByName.set(nameKey, existingId);
|
|
2049
|
+
}
|
|
2050
|
+
if (!waitForReady)
|
|
2051
|
+
return instance;
|
|
2052
|
+
return waitForEntityIdle(instance, { timeout: readyTimeout }).then(() => instance);
|
|
2053
|
+
}
|
|
2054
|
+
// Step 7: Create new instance with id in initial context
|
|
2055
|
+
// This ensures loadOrCreateModel sees the ID immediately
|
|
2056
|
+
// Pass _pendingPropertyDefinitions in initial context to avoid race condition with loadOrCreateModel
|
|
2057
|
+
// This is the proper XState way - include it in the initial input so it's available from the start
|
|
2058
|
+
const initialContext = options?.properties
|
|
2059
|
+
? { _pendingPropertyDefinitions: JSON.parse(JSON.stringify(options.properties)) }
|
|
2060
|
+
: undefined;
|
|
2061
|
+
// Use the unique model name for the instance (idFromSchema: true when id was provided, so schema model is not renamed)
|
|
2062
|
+
const newInstance = new this(uniqueModelName, schemaName, resolvedId, initialContext, !!id);
|
|
2063
|
+
if (options?.properties) {
|
|
2064
|
+
logger(`[Model.create] Created instance with _pendingPropertyDefinitions in initial context (${Object.keys(options.properties).length} properties) for model "${uniqueModelName}"`);
|
|
2065
|
+
}
|
|
2066
|
+
// Step 7: Set initial model data if provided
|
|
2067
|
+
// Note: description is not supported - JSON files can have it but we ignore it at runtime
|
|
2068
|
+
if (options) {
|
|
2069
|
+
const updates = {};
|
|
2070
|
+
if (Object.keys(updates).length > 0) {
|
|
2071
|
+
newInstance._service.send({
|
|
2072
|
+
type: 'updateContext',
|
|
2073
|
+
...updates,
|
|
2074
|
+
});
|
|
2075
|
+
// Initialize original values
|
|
2076
|
+
// Note: properties are not stored in context, they're computed from ModelProperty instances
|
|
2077
|
+
newInstance._service.send({
|
|
2078
|
+
type: 'initializeOriginalValues',
|
|
2079
|
+
originalValues: {},
|
|
2080
|
+
isEdited: false,
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
// Wrap instance in Proxy for reactive property access
|
|
2085
|
+
// Create a custom proxy that handles 'name' as an alias for 'modelName'
|
|
2086
|
+
const proxiedInstance = new Proxy(newInstance, {
|
|
2087
|
+
get(target, prop) {
|
|
2088
|
+
// Handle special properties
|
|
2089
|
+
if (prop === '_service') {
|
|
2090
|
+
return Reflect.get(target, prop);
|
|
2091
|
+
}
|
|
2092
|
+
// Handle 'name' as an alias for 'modelName'
|
|
2093
|
+
if (prop === 'name') {
|
|
2094
|
+
const context = target._getSnapshotContext();
|
|
2095
|
+
return context.modelName;
|
|
2096
|
+
}
|
|
2097
|
+
// Handle 'id' property - returns the schemaFileId (string)
|
|
2098
|
+
if (prop === 'id') {
|
|
2099
|
+
const context = target._getSnapshotContext();
|
|
2100
|
+
return context.id; // id is now the schemaFileId (string)
|
|
2101
|
+
}
|
|
2102
|
+
// Handle tracked properties
|
|
2103
|
+
if (typeof prop === 'string' && TRACKED_PROPERTIES.includes(prop)) {
|
|
2104
|
+
const context = target._getSnapshotContext();
|
|
2105
|
+
return context[prop];
|
|
2106
|
+
}
|
|
2107
|
+
// Handle 'properties' getter - not tracked, but accessible via getter
|
|
2108
|
+
if (typeof prop === 'string' && prop === 'properties') {
|
|
2109
|
+
// Delegate to the getter method on the instance
|
|
2110
|
+
return target.properties;
|
|
2111
|
+
}
|
|
2112
|
+
// For methods and other properties, use Reflect
|
|
2113
|
+
return Reflect.get(target, prop);
|
|
2114
|
+
},
|
|
2115
|
+
set(target, prop, value) {
|
|
2116
|
+
// Handle special properties
|
|
2117
|
+
if (prop === '_service') {
|
|
2118
|
+
return Reflect.set(target, prop, value);
|
|
2119
|
+
}
|
|
2120
|
+
// Handle 'name' as an alias for 'modelName'
|
|
2121
|
+
if (prop === 'name') {
|
|
2122
|
+
prop = 'modelName';
|
|
2123
|
+
}
|
|
2124
|
+
// Handle tracked properties
|
|
2125
|
+
if (typeof prop === 'string' && TRACKED_PROPERTIES.includes(prop)) {
|
|
2126
|
+
// Use the sendUpdate logic
|
|
2127
|
+
if (prop === 'modelName') {
|
|
2128
|
+
// Model name change - need to update Schema and database
|
|
2129
|
+
const context = target._getSnapshotContext();
|
|
2130
|
+
const oldName = context.modelName;
|
|
2131
|
+
const newName = value;
|
|
2132
|
+
if (oldName === newName) {
|
|
2133
|
+
logger(`Model name unchanged: "${oldName}"`);
|
|
2134
|
+
return true;
|
|
2135
|
+
}
|
|
2136
|
+
logger(`Updating model name from "${oldName}" to "${newName}"`);
|
|
2137
|
+
// Update Model instance cache using new ID-based structure
|
|
2138
|
+
const modelFileId = context.id; // id is now the schemaFileId (string)
|
|
2139
|
+
if (modelFileId) {
|
|
2140
|
+
Model.updateNameIndex(oldName, newName, context.schemaName, modelFileId);
|
|
2141
|
+
}
|
|
2142
|
+
else {
|
|
2143
|
+
// Fallback to legacy cache if no ID (shouldn't happen, but safety)
|
|
2144
|
+
const oldCacheKey = `${context.schemaName}:${oldName}`;
|
|
2145
|
+
const newCacheKey = `${context.schemaName}:${newName}`;
|
|
2146
|
+
if (Model.instanceCache.has(oldCacheKey)) {
|
|
2147
|
+
const entry = Model.instanceCache.get(oldCacheKey);
|
|
2148
|
+
Model.instanceCache.delete(oldCacheKey);
|
|
2149
|
+
Model.instanceCache.set(newCacheKey, entry);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
// Update Model context
|
|
2153
|
+
target._service.send({
|
|
2154
|
+
type: 'updateContext',
|
|
2155
|
+
modelName: newName,
|
|
2156
|
+
});
|
|
2157
|
+
// Mark model as draft
|
|
2158
|
+
target._service.send({
|
|
2159
|
+
type: 'markAsDraft',
|
|
2160
|
+
propertyKey: 'modelName',
|
|
2161
|
+
});
|
|
2162
|
+
// Save draft to database immediately so changes persist
|
|
2163
|
+
target._saveDraftToDb(oldName, newName).catch((error) => {
|
|
2164
|
+
logger(`Failed to save draft to database: ${error instanceof Error ? error.message : String(error)}`);
|
|
2165
|
+
});
|
|
2166
|
+
// NOTE: Schema is not notified of model name changes during edits.
|
|
2167
|
+
// Schema will read from Model instances when persisting/saving.
|
|
2168
|
+
}
|
|
2169
|
+
else if (prop === 'properties') {
|
|
2170
|
+
// Properties are read-only computed values from ModelProperty instances
|
|
2171
|
+
// Cannot be set directly - properties are managed via ModelProperty instances
|
|
2172
|
+
throw new Error('Cannot set model.properties directly. Properties are computed from ModelProperty instances.');
|
|
2173
|
+
}
|
|
2174
|
+
else if (prop === 'description') {
|
|
2175
|
+
// Description is not supported - JSON files can have it but we ignore it at runtime
|
|
2176
|
+
throw new Error('Cannot set model.description. Description is not supported in runtime Model instances.');
|
|
2177
|
+
}
|
|
2178
|
+
else {
|
|
2179
|
+
// Standard property update
|
|
2180
|
+
target._service.send({
|
|
2181
|
+
type: 'updateContext',
|
|
2182
|
+
[prop]: value,
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
return true;
|
|
2186
|
+
}
|
|
2187
|
+
// For non-tracked properties, use Reflect
|
|
2188
|
+
return Reflect.set(target, prop, value);
|
|
2189
|
+
},
|
|
2190
|
+
has(target, prop) {
|
|
2191
|
+
// Handle 'name' as an alias for 'modelName'
|
|
2192
|
+
if (prop === 'name') {
|
|
2193
|
+
return true;
|
|
2194
|
+
}
|
|
2195
|
+
// Handle 'id' property
|
|
2196
|
+
if (prop === 'id') {
|
|
2197
|
+
return true;
|
|
2198
|
+
}
|
|
2199
|
+
// Check tracked properties
|
|
2200
|
+
if (typeof prop === 'string' && TRACKED_PROPERTIES.includes(prop)) {
|
|
2201
|
+
const context = target._getSnapshotContext();
|
|
2202
|
+
return prop in context;
|
|
2203
|
+
}
|
|
2204
|
+
return Reflect.has(target, prop);
|
|
2205
|
+
},
|
|
2206
|
+
ownKeys(target) {
|
|
2207
|
+
// Include 'name' and 'id' in ownKeys
|
|
2208
|
+
const keys = Reflect.ownKeys(target);
|
|
2209
|
+
const additionalKeys = [];
|
|
2210
|
+
if (!keys.includes('name')) {
|
|
2211
|
+
additionalKeys.push('name');
|
|
2212
|
+
}
|
|
2213
|
+
if (!keys.includes('id')) {
|
|
2214
|
+
additionalKeys.push('id');
|
|
2215
|
+
}
|
|
2216
|
+
return additionalKeys.length > 0 ? [...keys, ...additionalKeys] : keys;
|
|
2217
|
+
},
|
|
2218
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
2219
|
+
// Handle 'name' as an alias for 'modelName'
|
|
2220
|
+
if (prop === 'name') {
|
|
2221
|
+
return getInvariantSafePropertyDescriptor(target, prop, {
|
|
2222
|
+
value: target._getSnapshotContext().modelName,
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
// Handle 'id' property - returns the modelFileId
|
|
2226
|
+
if (prop === 'id') {
|
|
2227
|
+
return getInvariantSafePropertyDescriptor(target, prop, {
|
|
2228
|
+
value: target._getSnapshotContext().id, // id is now the schemaFileId (string)
|
|
2229
|
+
writable: false, // id is read-only
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
let invent;
|
|
2233
|
+
if (typeof prop === 'string' && TRACKED_PROPERTIES.includes(prop)) {
|
|
2234
|
+
const context = target._getSnapshotContext();
|
|
2235
|
+
if (prop in context) {
|
|
2236
|
+
invent = { value: context[prop] };
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
return getInvariantSafePropertyDescriptor(target, prop, invent);
|
|
2240
|
+
},
|
|
2241
|
+
});
|
|
2242
|
+
// Step 8: Store in new ID-based cache
|
|
2243
|
+
// resolvedId is guaranteed to be defined at this point (generated if not provided)
|
|
2244
|
+
this.instanceCacheById.set(resolvedId, {
|
|
2245
|
+
instance: proxiedInstance,
|
|
2246
|
+
refCount: 1,
|
|
2247
|
+
});
|
|
2248
|
+
// Step 9: Store in name-based index (placeholder key when !id; loadOrCreateModel will replace with final name)
|
|
2249
|
+
this.instanceCacheByName.set(nameKey, resolvedId);
|
|
2250
|
+
// Step 9b: When created with id (schema model) and unique name, also register original name so create(modelName) returns same instance
|
|
2251
|
+
if (id && uniqueModelName !== modelName) {
|
|
2252
|
+
this.instanceCacheByName.set(originalNameKey, resolvedId);
|
|
2253
|
+
}
|
|
2254
|
+
// Step 10: Also store in legacy cache (for backward compatibility during migration)
|
|
2255
|
+
this.instanceCache.set(nameKey, {
|
|
2256
|
+
instance: proxiedInstance,
|
|
2257
|
+
refCount: 1,
|
|
2258
|
+
});
|
|
2259
|
+
logger(`Model.create: Created new instance for "${uniqueModelName}" (ID: ${id})`);
|
|
2260
|
+
// Model instance is now cached and accessible via Model.getById() and Model.getByName()
|
|
2261
|
+
// No wrapper or store registration needed
|
|
2262
|
+
// Step 11: Register with schema if requested, OR trigger write if properties are provided
|
|
2263
|
+
// If properties are provided, we need to write the model to get modelId for property creation
|
|
2264
|
+
// If schema provided, trigger write process instead of registration
|
|
2265
|
+
// When schemaName is passed as string (e.g. useCreateModel), we must trigger write so runtime-created
|
|
2266
|
+
// models get persisted to DB and show up in useModels (which queries the database).
|
|
2267
|
+
const shouldTriggerWrite = (registerWithSchema && schemaInstance) ||
|
|
2268
|
+
(options?.properties && Object.keys(options.properties).length > 0) ||
|
|
2269
|
+
typeof schemaNameOrSchema === 'string';
|
|
2270
|
+
if (shouldTriggerWrite) {
|
|
2271
|
+
queueMicrotask(async () => {
|
|
2272
|
+
try {
|
|
2273
|
+
// Wait for model to reach idle state (validation complete)
|
|
2274
|
+
await new Promise((resolve) => {
|
|
2275
|
+
const subscription = proxiedInstance._service.subscribe((snapshot) => {
|
|
2276
|
+
// Wait for idle state (validation complete) or error state
|
|
2277
|
+
if (snapshot.value === 'idle' || snapshot.value === 'error') {
|
|
2278
|
+
subscription.unsubscribe();
|
|
2279
|
+
resolve();
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
// Timeout after 10 seconds
|
|
2283
|
+
setTimeout(() => {
|
|
2284
|
+
subscription.unsubscribe();
|
|
2285
|
+
resolve();
|
|
2286
|
+
}, 10000);
|
|
2287
|
+
});
|
|
2288
|
+
// Only write if validation passed (model is in idle state, not error)
|
|
2289
|
+
const finalSnapshot = proxiedInstance._service.getSnapshot();
|
|
2290
|
+
if (finalSnapshot.value === 'idle' && (!finalSnapshot.context._validationErrors || finalSnapshot.context._validationErrors.length === 0)) {
|
|
2291
|
+
logger(`Model validation passed, triggering write process`);
|
|
2292
|
+
// Get schema ID from schema instance or look it up by schema name
|
|
2293
|
+
let schemaId;
|
|
2294
|
+
if (schemaInstance) {
|
|
2295
|
+
const schemaContext = schemaInstance._getSnapshotContext();
|
|
2296
|
+
// First, try to use _dbId directly from schema context (most reliable)
|
|
2297
|
+
// But verify it exists in database before using it
|
|
2298
|
+
if (schemaContext._dbId && typeof schemaContext._dbId === 'number' && schemaContext._dbId > 0) {
|
|
2299
|
+
try {
|
|
2300
|
+
const db = BaseDb.getAppDb();
|
|
2301
|
+
if (db) {
|
|
2302
|
+
const verifySchema = await db
|
|
2303
|
+
.select({ id: schemas.id })
|
|
2304
|
+
.from(schemas)
|
|
2305
|
+
.where(eq(schemas.id, schemaContext._dbId))
|
|
2306
|
+
.limit(1);
|
|
2307
|
+
if (verifySchema.length > 0) {
|
|
2308
|
+
schemaId = schemaContext._dbId;
|
|
2309
|
+
logger(`Using schema _dbId directly from Schema context: ${schemaId} (verified in database)`);
|
|
2310
|
+
}
|
|
2311
|
+
else {
|
|
2312
|
+
logger(`WARNING: Schema _dbId ${schemaContext._dbId} from context does not exist in database. Falling back to schemaFileId lookup.`);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
catch (error) {
|
|
2317
|
+
logger(`WARNING: Could not verify schema _dbId: ${error}. Falling back to schemaFileId lookup.`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
// Fall back to database lookup by schemaFileId if _dbId wasn't available or invalid
|
|
2321
|
+
if (!schemaId) {
|
|
2322
|
+
// Fall back to database lookup by schemaFileId
|
|
2323
|
+
const schemaFileId = schemaContext.id; // id is the schemaFileId (string) in SchemaMachineContext
|
|
2324
|
+
if (schemaFileId) {
|
|
2325
|
+
try {
|
|
2326
|
+
const db = BaseDb.getAppDb();
|
|
2327
|
+
if (db) {
|
|
2328
|
+
const schemaRecords = await db
|
|
2329
|
+
.select()
|
|
2330
|
+
.from(schemas)
|
|
2331
|
+
.where(eq(schemas.schemaFileId, schemaFileId))
|
|
2332
|
+
.limit(1);
|
|
2333
|
+
if (schemaRecords.length > 0) {
|
|
2334
|
+
schemaId = schemaRecords[0].id;
|
|
2335
|
+
logger(`Found schemaId ${schemaId} by schemaFileId lookup: ${schemaFileId}`);
|
|
2336
|
+
}
|
|
2337
|
+
else {
|
|
2338
|
+
logger(`WARNING: Schema not found in database by schemaFileId: ${schemaFileId}. Schema may not be fully loaded yet.`);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
catch (error) {
|
|
2343
|
+
logger(`Error looking up schemaId: ${error}`);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
else {
|
|
2347
|
+
logger(`WARNING: Schema instance has no schemaFileId (id) in context`);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
else if (schemaName) {
|
|
2352
|
+
// If no schema instance but we have schema name, look it up by name
|
|
2353
|
+
try {
|
|
2354
|
+
const db = BaseDb.getAppDb();
|
|
2355
|
+
if (db) {
|
|
2356
|
+
const schemaRecords = await db
|
|
2357
|
+
.select()
|
|
2358
|
+
.from(schemas)
|
|
2359
|
+
.where(eq(schemas.name, schemaName))
|
|
2360
|
+
.limit(1);
|
|
2361
|
+
if (schemaRecords.length > 0) {
|
|
2362
|
+
schemaId = schemaRecords[0].id;
|
|
2363
|
+
logger(`Found schemaId ${schemaId} by schema name lookup: ${schemaName}`);
|
|
2364
|
+
}
|
|
2365
|
+
else {
|
|
2366
|
+
logger(`WARNING: Schema not found in database by name: ${schemaName}`);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
catch (error) {
|
|
2371
|
+
logger(`Error looking up schemaId by name: ${error}`);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
// Validate schemaId before proceeding
|
|
2375
|
+
if (!schemaId || !Number.isInteger(schemaId) || schemaId <= 0) {
|
|
2376
|
+
logger(`ERROR: Invalid schemaId (${schemaId}) for model "${finalSnapshot.context.modelName}". Cannot trigger write process.`);
|
|
2377
|
+
logger(`Schema context:`, {
|
|
2378
|
+
schemaFileId: schemaInstance ? schemaInstance._getSnapshotContext().id : 'N/A',
|
|
2379
|
+
_dbId: schemaInstance ? schemaInstance._getSnapshotContext()._dbId : 'N/A',
|
|
2380
|
+
schemaName: schemaName || 'N/A',
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
else if (finalSnapshot.context.id) {
|
|
2384
|
+
// Verify schema exists in database before proceeding (additional safety check)
|
|
2385
|
+
try {
|
|
2386
|
+
const db = BaseDb.getAppDb();
|
|
2387
|
+
if (db) {
|
|
2388
|
+
const schemaCheck = await db
|
|
2389
|
+
.select({ id: schemas.id })
|
|
2390
|
+
.from(schemas)
|
|
2391
|
+
.where(eq(schemas.id, schemaId))
|
|
2392
|
+
.limit(1);
|
|
2393
|
+
if (schemaCheck.length === 0) {
|
|
2394
|
+
const errorMsg = `Schema with id ${schemaId} does not exist in database. Cannot create model "${finalSnapshot.context.modelName}".`;
|
|
2395
|
+
logger(`ERROR: ${errorMsg}`);
|
|
2396
|
+
throw new Error(errorMsg);
|
|
2397
|
+
}
|
|
2398
|
+
logger(`Verified schema ${schemaId} exists in database before creating model "${finalSnapshot.context.modelName}"`);
|
|
2399
|
+
}
|
|
2400
|
+
else {
|
|
2401
|
+
logger(`WARNING: Database not available for schema verification. Proceeding anyway.`);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
catch (error) {
|
|
2405
|
+
// If it's our validation error, re-throw it
|
|
2406
|
+
if (error instanceof Error && error.message.includes('does not exist in database')) {
|
|
2407
|
+
throw error;
|
|
2408
|
+
}
|
|
2409
|
+
logger(`WARNING: Could not verify schema exists in database: ${error}. Proceeding anyway.`);
|
|
2410
|
+
}
|
|
2411
|
+
// Track pending write
|
|
2412
|
+
Model.trackPendingWrite(finalSnapshot.context.id, schemaId); // id is now the schemaFileId (string)
|
|
2413
|
+
// Wait for writeProcess to be spawned (it's spawned in idle state entry action)
|
|
2414
|
+
// Retry a few times if writeProcess isn't available yet
|
|
2415
|
+
let retries = 0;
|
|
2416
|
+
const maxRetries = 10;
|
|
2417
|
+
const checkAndSend = async () => {
|
|
2418
|
+
const currentSnapshot = proxiedInstance._service.getSnapshot();
|
|
2419
|
+
if (currentSnapshot.context.writeProcess) {
|
|
2420
|
+
logger(`Triggering write process for model "${finalSnapshot.context.modelName}" (schemaId: ${schemaId})`);
|
|
2421
|
+
// Use pending property definitions if available, otherwise convert from ModelProperty instances
|
|
2422
|
+
let propertiesObject = {};
|
|
2423
|
+
if (currentSnapshot.context._pendingPropertyDefinitions) {
|
|
2424
|
+
// Use the original property definitions for the write
|
|
2425
|
+
// Convert dataType to type for validation compatibility
|
|
2426
|
+
propertiesObject = JSON.parse(JSON.stringify(currentSnapshot.context._pendingPropertyDefinitions));
|
|
2427
|
+
for (const [propName, propData] of Object.entries(propertiesObject)) {
|
|
2428
|
+
if (propData.dataType && !propData.type) {
|
|
2429
|
+
propertiesObject[propName] = {
|
|
2430
|
+
...propData,
|
|
2431
|
+
type: propData.dataType,
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
else {
|
|
2437
|
+
// Fallback: convert ModelProperty instances to object format
|
|
2438
|
+
const properties = proxiedInstance.properties || [];
|
|
2439
|
+
propertiesObject = modelPropertiesToObject(properties);
|
|
2440
|
+
}
|
|
2441
|
+
// Set up subscription to writeProcess to detect write success BEFORE sending request
|
|
2442
|
+
const writeProcess = currentSnapshot.context.writeProcess;
|
|
2443
|
+
// Check current state - if already in success, handle immediately
|
|
2444
|
+
const currentWriteState = writeProcess.getSnapshot();
|
|
2445
|
+
if (currentWriteState.value === 'success') {
|
|
2446
|
+
proxiedInstance._service.send({
|
|
2447
|
+
type: 'writeSuccess',
|
|
2448
|
+
output: currentWriteState.context.entityData,
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
else {
|
|
2452
|
+
// Set up subscription to catch future state changes
|
|
2453
|
+
const writeSubscription = writeProcess.subscribe((writeSnapshot) => {
|
|
2454
|
+
if (writeSnapshot.value === 'success') {
|
|
2455
|
+
writeSubscription.unsubscribe();
|
|
2456
|
+
const currentContext = proxiedInstance._service.getSnapshot().context;
|
|
2457
|
+
logger(`[writeProcess subscription] Write succeeded, sending writeSuccess event. Has _pendingPropertyDefinitions: ${!!(currentContext._pendingPropertyDefinitions && Object.keys(currentContext._pendingPropertyDefinitions).length > 0)}`);
|
|
2458
|
+
// Send writeSuccess event to model machine
|
|
2459
|
+
proxiedInstance._service.send({
|
|
2460
|
+
type: 'writeSuccess',
|
|
2461
|
+
output: writeSnapshot.context.entityData,
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
else if (writeSnapshot.value === 'error') {
|
|
2465
|
+
writeSubscription.unsubscribe();
|
|
2466
|
+
const errorContext = writeSnapshot.context;
|
|
2467
|
+
logger(`Write process failed for model "${finalSnapshot.context.modelName}": ${errorContext.error?.message || 'Unknown error'}`);
|
|
2468
|
+
logger(`Write process error details:`, errorContext.error);
|
|
2469
|
+
logger(`Validation errors:`, errorContext.validationErrors);
|
|
2470
|
+
}
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
proxiedInstance._service.send({
|
|
2474
|
+
type: 'requestWrite',
|
|
2475
|
+
data: {
|
|
2476
|
+
modelFileId: finalSnapshot.context.id, // id is now the schemaFileId (string)
|
|
2477
|
+
modelName: finalSnapshot.context.modelName,
|
|
2478
|
+
schemaName: finalSnapshot.context.schemaName,
|
|
2479
|
+
schemaId,
|
|
2480
|
+
properties: propertiesObject,
|
|
2481
|
+
},
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
else if (retries < maxRetries) {
|
|
2485
|
+
retries++;
|
|
2486
|
+
setTimeout(checkAndSend, 50); // Retry after 50ms
|
|
2487
|
+
}
|
|
2488
|
+
else {
|
|
2489
|
+
logger(`ERROR: writeProcess not available after ${maxRetries} retries for model "${finalSnapshot.context.modelName}"`);
|
|
2490
|
+
}
|
|
2491
|
+
};
|
|
2492
|
+
// Start checking immediately, but also allow time for entry action to complete
|
|
2493
|
+
setTimeout(checkAndSend, 0);
|
|
2494
|
+
}
|
|
2495
|
+
else {
|
|
2496
|
+
logger(`Cannot trigger write process: missing schemaId (${schemaId}) or id (${finalSnapshot.context.id})`);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
else {
|
|
2500
|
+
logger(`Model validation failed or model in error state, skipping write process`);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
catch (error) {
|
|
2504
|
+
logger(`Failed to trigger write process: ${error instanceof Error ? error.message : String(error)}`);
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
if (!waitForReady)
|
|
2509
|
+
return proxiedInstance;
|
|
2510
|
+
return waitForEntityIdle(proxiedInstance, { timeout: readyTimeout }).then(() => proxiedInstance);
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Get Model instance by modelFileId (O(1) lookup)
|
|
2514
|
+
*/
|
|
2515
|
+
static getById(modelFileId) {
|
|
2516
|
+
const entry = this.instanceCacheById.get(modelFileId);
|
|
2517
|
+
if (entry) {
|
|
2518
|
+
// Increment ref count
|
|
2519
|
+
this.instanceCacheById.set(modelFileId, {
|
|
2520
|
+
instance: entry.instance,
|
|
2521
|
+
refCount: entry.refCount + 1,
|
|
2522
|
+
});
|
|
2523
|
+
return entry.instance;
|
|
2524
|
+
}
|
|
2525
|
+
return undefined;
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* Get Model instance by name (O(1) lookup via name→ID mapping)
|
|
2529
|
+
*
|
|
2530
|
+
* @param modelName - The name of the model
|
|
2531
|
+
* @param schemaName - The schema name (optional, will query DB if not provided)
|
|
2532
|
+
* @returns The Model instance if found, undefined otherwise
|
|
2533
|
+
*/
|
|
2534
|
+
static getByName(modelName, schemaName) {
|
|
2535
|
+
if (schemaName) {
|
|
2536
|
+
const nameKey = `${schemaName}:${modelName}`;
|
|
2537
|
+
const id = this.instanceCacheByName.get(nameKey);
|
|
2538
|
+
if (!id) {
|
|
2539
|
+
return undefined;
|
|
2540
|
+
}
|
|
2541
|
+
return this.getById(id);
|
|
2542
|
+
}
|
|
2543
|
+
// If schemaName not provided, try to find in cache by searching all name keys
|
|
2544
|
+
for (const [nameKey, id] of this.instanceCacheByName.entries()) {
|
|
2545
|
+
const [, cachedModelName] = nameKey.split(':');
|
|
2546
|
+
if (cachedModelName === modelName) {
|
|
2547
|
+
return this.getById(id);
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
return undefined;
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* Find Model by modelType (snake_case from DB/metadata).
|
|
2554
|
+
* Handles model names with spaces: "new_model" -> finds "New model" (toSnakeCase("New model") === "new_model").
|
|
2555
|
+
*/
|
|
2556
|
+
static findByModelType(modelType) {
|
|
2557
|
+
if (!modelType)
|
|
2558
|
+
return undefined;
|
|
2559
|
+
for (const [nameKey, id] of this.instanceCacheByName.entries()) {
|
|
2560
|
+
const parts = nameKey.split(':', 2);
|
|
2561
|
+
const cachedModelName = parts[1];
|
|
2562
|
+
if (cachedModelName && toSnakeCase(cachedModelName) === modelType) {
|
|
2563
|
+
return this.getById(id);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
return undefined;
|
|
2567
|
+
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Get all Model instances for a schema from cache only (synchronous).
|
|
2570
|
+
* Includes models created at runtime via Model.create() that may not yet be in schema context.
|
|
2571
|
+
*/
|
|
2572
|
+
static getCachedInstancesForSchema(schemaName) {
|
|
2573
|
+
const instances = [];
|
|
2574
|
+
const seen = new Set();
|
|
2575
|
+
for (const [nameKey, id] of this.instanceCacheByName.entries()) {
|
|
2576
|
+
const [cachedSchemaName] = nameKey.split(':');
|
|
2577
|
+
if (cachedSchemaName === schemaName && id && !seen.has(id)) {
|
|
2578
|
+
const instance = this.getById(id);
|
|
2579
|
+
if (instance) {
|
|
2580
|
+
instances.push(instance);
|
|
2581
|
+
seen.add(id);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
for (const [nameKey, entry] of this.instanceCache.entries()) {
|
|
2586
|
+
const [cachedSchemaName] = nameKey.split(':');
|
|
2587
|
+
if (cachedSchemaName === schemaName) {
|
|
2588
|
+
const context = entry.instance._getSnapshotContext();
|
|
2589
|
+
const id = context.id;
|
|
2590
|
+
if (id && !seen.has(id)) {
|
|
2591
|
+
instances.push(entry.instance);
|
|
2592
|
+
seen.add(id);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
return instances;
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Get Model instance by name, querying database if not in cache
|
|
2600
|
+
* This is an async version that can query the database when schemaName is not provided
|
|
2601
|
+
*
|
|
2602
|
+
* @param modelName - The name of the model
|
|
2603
|
+
* @param schemaName - Optional schema name (will query DB if not provided)
|
|
2604
|
+
* @returns The Model instance if found, undefined otherwise
|
|
2605
|
+
*/
|
|
2606
|
+
static async getByNameAsync(modelName, schemaName) {
|
|
2607
|
+
// First try cache
|
|
2608
|
+
const cached = this.getByName(modelName, schemaName);
|
|
2609
|
+
if (cached) {
|
|
2610
|
+
return cached;
|
|
2611
|
+
}
|
|
2612
|
+
// If not in cache and schemaName not provided, query database
|
|
2613
|
+
if (!schemaName) {
|
|
2614
|
+
const db = BaseDb.getAppDb();
|
|
2615
|
+
if (!db) {
|
|
2616
|
+
return undefined;
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
// Query model by name
|
|
2620
|
+
const modelRecords = await db
|
|
2621
|
+
.select({
|
|
2622
|
+
modelFileId: models.schemaFileId,
|
|
2623
|
+
modelName: models.name,
|
|
2624
|
+
schemaName: schemas.name,
|
|
2625
|
+
})
|
|
2626
|
+
.from(models)
|
|
2627
|
+
.innerJoin(modelSchemas, eq(models.id, modelSchemas.modelId))
|
|
2628
|
+
.innerJoin(schemas, eq(modelSchemas.schemaId, schemas.id))
|
|
2629
|
+
.where(eq(models.name, modelName))
|
|
2630
|
+
.limit(1);
|
|
2631
|
+
if (modelRecords.length === 0) {
|
|
2632
|
+
return undefined;
|
|
2633
|
+
}
|
|
2634
|
+
const record = modelRecords[0];
|
|
2635
|
+
if (!record.modelName || !record.schemaName || !record.modelFileId) {
|
|
2636
|
+
return undefined;
|
|
2637
|
+
}
|
|
2638
|
+
// Create model instance (will be cached)
|
|
2639
|
+
return this.create(record.modelName, record.schemaName, {
|
|
2640
|
+
id: record.modelFileId, // id is now the schemaFileId (string)
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
catch (error) {
|
|
2644
|
+
logger(`Model.getByNameAsync: Error looking up model by name "${modelName}": ${error instanceof Error ? error.message : String(error)}`);
|
|
2645
|
+
return undefined;
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
return undefined;
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Create or get Model instance by modelFileId
|
|
2652
|
+
* Checks cache first, then database if not found
|
|
2653
|
+
*
|
|
2654
|
+
* @param modelFileId - The model file ID to look up
|
|
2655
|
+
* @returns The Model instance if found, undefined otherwise
|
|
2656
|
+
*/
|
|
2657
|
+
static async createById(modelFileId) {
|
|
2658
|
+
if (!modelFileId) {
|
|
2659
|
+
return undefined;
|
|
2660
|
+
}
|
|
2661
|
+
// Step 1: Check cache first (fast path)
|
|
2662
|
+
const cached = this.getById(modelFileId);
|
|
2663
|
+
if (cached) {
|
|
2664
|
+
logger(`Model.createById: Found instance in cache for ID "${modelFileId}"`);
|
|
2665
|
+
return cached;
|
|
2666
|
+
}
|
|
2667
|
+
// Step 2: Query database to get modelName and schemaName
|
|
2668
|
+
const db = BaseDb.getAppDb();
|
|
2669
|
+
if (!db) {
|
|
2670
|
+
logger(`Model.createById: Database not available for ID "${modelFileId}"`);
|
|
2671
|
+
return undefined;
|
|
2672
|
+
}
|
|
2673
|
+
try {
|
|
2674
|
+
// Query model by schemaFileId and join to get schema name
|
|
2675
|
+
const modelRecords = await db
|
|
2676
|
+
.select({
|
|
2677
|
+
modelId: models.id,
|
|
2678
|
+
modelName: models.name,
|
|
2679
|
+
schemaName: schemas.name,
|
|
2680
|
+
})
|
|
2681
|
+
.from(models)
|
|
2682
|
+
.innerJoin(modelSchemas, eq(models.id, modelSchemas.modelId))
|
|
2683
|
+
.innerJoin(schemas, eq(modelSchemas.schemaId, schemas.id))
|
|
2684
|
+
.where(eq(models.schemaFileId, modelFileId))
|
|
2685
|
+
.limit(1);
|
|
2686
|
+
if (modelRecords.length === 0) {
|
|
2687
|
+
logger(`Model.createById: Model not found in database for ID "${modelFileId}"`);
|
|
2688
|
+
return undefined;
|
|
2689
|
+
}
|
|
2690
|
+
const { modelName, schemaName } = modelRecords[0];
|
|
2691
|
+
if (!modelName || !schemaName) {
|
|
2692
|
+
logger(`Model.createById: Missing modelName or schemaName for ID "${modelFileId}"`);
|
|
2693
|
+
return undefined;
|
|
2694
|
+
}
|
|
2695
|
+
// Step 3: Create model using existing create method (which will handle caching)
|
|
2696
|
+
logger(`Model.createById: Creating model "${modelName}" in schema "${schemaName}" from ID "${modelFileId}"`);
|
|
2697
|
+
return this.create(modelName, schemaName, {
|
|
2698
|
+
id: modelFileId, // id is now the schemaFileId (string)
|
|
2699
|
+
});
|
|
2700
|
+
}
|
|
2701
|
+
catch (error) {
|
|
2702
|
+
logger(`Model.createById: Error looking up model by ID "${modelFileId}": ${error instanceof Error ? error.message : String(error)}`);
|
|
2703
|
+
return undefined;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Find Model instance by modelFileId, modelName/schemaName, or both
|
|
2708
|
+
* Waits for the model to be fully loaded (idle state) by default
|
|
2709
|
+
* @param options - Find options including lookup parameters and wait configuration
|
|
2710
|
+
* @returns Model instance if found, undefined otherwise
|
|
2711
|
+
*/
|
|
2712
|
+
static async find({ modelFileId, modelName, schemaName, waitForReady = true, readyTimeout = 5000, }) {
|
|
2713
|
+
return await findEntity({
|
|
2714
|
+
getById: (id) => Model.getById(id),
|
|
2715
|
+
createById: (id) => Model.createById(id),
|
|
2716
|
+
getByName: (name, ...args) => {
|
|
2717
|
+
const schemaName = args[0];
|
|
2718
|
+
return Model.getByName(name, schemaName);
|
|
2719
|
+
},
|
|
2720
|
+
createByName: async (name, ...args) => {
|
|
2721
|
+
const schemaName = args[0];
|
|
2722
|
+
return Model.getByNameAsync(name, schemaName);
|
|
2723
|
+
},
|
|
2724
|
+
}, {
|
|
2725
|
+
id: modelFileId,
|
|
2726
|
+
name: modelName,
|
|
2727
|
+
schemaName,
|
|
2728
|
+
}, {
|
|
2729
|
+
waitForReady,
|
|
2730
|
+
readyTimeout,
|
|
2731
|
+
});
|
|
2732
|
+
}
|
|
2733
|
+
/**
|
|
2734
|
+
* Get all Model instances for a schema by schemaFileId or schemaName
|
|
2735
|
+
* Queries database for all models with the given schemaId/schemaName and returns Model instances
|
|
2736
|
+
*
|
|
2737
|
+
* @param schemaIdentifier - The schema file ID or schema name to get models for
|
|
2738
|
+
* @returns Array of Model instances for the schema
|
|
2739
|
+
*/
|
|
2740
|
+
static async createBySchemaId(schemaIdentifier) {
|
|
2741
|
+
if (!schemaIdentifier) {
|
|
2742
|
+
return [];
|
|
2743
|
+
}
|
|
2744
|
+
const db = BaseDb.getAppDb();
|
|
2745
|
+
if (!db) {
|
|
2746
|
+
logger(`Model.createBySchemaId: Database not available for schema "${schemaIdentifier}"`);
|
|
2747
|
+
return [];
|
|
2748
|
+
}
|
|
2749
|
+
try {
|
|
2750
|
+
// Query all models for this schema - support both schemaFileId and schema name
|
|
2751
|
+
const modelRecords = await db
|
|
2752
|
+
.select({
|
|
2753
|
+
modelFileId: models.schemaFileId,
|
|
2754
|
+
modelName: models.name,
|
|
2755
|
+
schemaName: schemas.name,
|
|
2756
|
+
})
|
|
2757
|
+
.from(schemas)
|
|
2758
|
+
.innerJoin(modelSchemas, eq(schemas.id, modelSchemas.schemaId))
|
|
2759
|
+
.innerJoin(models, eq(modelSchemas.modelId, models.id))
|
|
2760
|
+
.where(or(eq(schemas.schemaFileId, schemaIdentifier), eq(schemas.name, schemaIdentifier)));
|
|
2761
|
+
if (modelRecords.length === 0) {
|
|
2762
|
+
logger(`Model.createBySchemaId: No models found in database for schema "${schemaIdentifier}"`);
|
|
2763
|
+
return [];
|
|
2764
|
+
}
|
|
2765
|
+
// Create Model instances for each model (create() handles caching)
|
|
2766
|
+
const modelInstances = [];
|
|
2767
|
+
for (const record of modelRecords) {
|
|
2768
|
+
if (!record.modelName || !record.schemaName || !record.modelFileId) {
|
|
2769
|
+
continue;
|
|
2770
|
+
}
|
|
2771
|
+
// Check cache first
|
|
2772
|
+
const cached = this.getById(record.modelFileId);
|
|
2773
|
+
if (cached) {
|
|
2774
|
+
modelInstances.push(cached);
|
|
2775
|
+
}
|
|
2776
|
+
else {
|
|
2777
|
+
// Create model instance (will be cached)
|
|
2778
|
+
const model = this.create(record.modelName, record.schemaName, {
|
|
2779
|
+
id: record.modelFileId, // id is now the schemaFileId (string)
|
|
2780
|
+
waitForReady: false,
|
|
2781
|
+
});
|
|
2782
|
+
modelInstances.push(model);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
logger(`Model.createBySchemaId: Found ${modelInstances.length} models for schema "${schemaIdentifier}"`);
|
|
2786
|
+
return modelInstances;
|
|
2787
|
+
}
|
|
2788
|
+
catch (error) {
|
|
2789
|
+
logger(`Model.createBySchemaId: Error looking up models for schema "${schemaIdentifier}": ${error instanceof Error ? error.message : String(error)}`);
|
|
2790
|
+
return [];
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
/**
|
|
2794
|
+
* Get all Model instances, optionally filtered by schema.
|
|
2795
|
+
* When DB is available, loads from DB via getModelsData; otherwise returns from cache.
|
|
2796
|
+
* Supports waitForReady to wait for each model to reach idle state before returning.
|
|
2797
|
+
*/
|
|
2798
|
+
static async all(schemaName, options) {
|
|
2799
|
+
const { waitForReady = false, readyTimeout = 5000 } = options ?? {};
|
|
2800
|
+
const appDb = BaseDb.getAppDb();
|
|
2801
|
+
if (!appDb) {
|
|
2802
|
+
const instances = [];
|
|
2803
|
+
const seen = new Set();
|
|
2804
|
+
if (schemaName !== undefined && schemaName !== '') {
|
|
2805
|
+
for (const [nameKey, id] of this.instanceCacheByName.entries()) {
|
|
2806
|
+
const [cachedSchemaName] = nameKey.split(':');
|
|
2807
|
+
if (cachedSchemaName === schemaName) {
|
|
2808
|
+
const instance = this.getById(id);
|
|
2809
|
+
if (instance && !seen.has(id)) {
|
|
2810
|
+
instances.push(instance);
|
|
2811
|
+
seen.add(id);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
for (const [nameKey, entry] of this.instanceCache.entries()) {
|
|
2816
|
+
const [cachedSchemaName] = nameKey.split(':');
|
|
2817
|
+
if (cachedSchemaName === schemaName) {
|
|
2818
|
+
const context = entry.instance._getSnapshotContext();
|
|
2819
|
+
const id = context.id;
|
|
2820
|
+
if (id && !seen.has(id)) {
|
|
2821
|
+
instances.push(entry.instance);
|
|
2822
|
+
seen.add(id);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
else {
|
|
2828
|
+
for (const [id, entry] of this.instanceCacheById.entries()) {
|
|
2829
|
+
if (!seen.has(id)) {
|
|
2830
|
+
instances.push(entry.instance);
|
|
2831
|
+
seen.add(id);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
for (const [nameKey, entry] of this.instanceCache.entries()) {
|
|
2835
|
+
const context = entry.instance._getSnapshotContext();
|
|
2836
|
+
const id = context.id;
|
|
2837
|
+
if (id && !seen.has(id)) {
|
|
2838
|
+
instances.push(entry.instance);
|
|
2839
|
+
seen.add(id);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
return instances;
|
|
2844
|
+
}
|
|
2845
|
+
const rows = await getModelsData(schemaName);
|
|
2846
|
+
const instances = [];
|
|
2847
|
+
const seen = new Set();
|
|
2848
|
+
for (const row of rows) {
|
|
2849
|
+
if (row.schemaFileId) {
|
|
2850
|
+
const instance = await this.createById(row.schemaFileId);
|
|
2851
|
+
if (instance) {
|
|
2852
|
+
instances.push(instance);
|
|
2853
|
+
seen.add(instance.id ?? row.schemaFileId);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
// Merge in any instances from cache for this schema (e.g. just-created model not yet visible in DB read).
|
|
2858
|
+
if (schemaName !== undefined && schemaName !== '') {
|
|
2859
|
+
for (const [nameKey, id] of this.instanceCacheByName.entries()) {
|
|
2860
|
+
const [cachedSchemaName] = nameKey.split(':');
|
|
2861
|
+
if (cachedSchemaName === schemaName && id && !seen.has(id)) {
|
|
2862
|
+
const instance = this.getById(id);
|
|
2863
|
+
if (instance) {
|
|
2864
|
+
instances.push(instance);
|
|
2865
|
+
seen.add(id);
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
for (const [nameKey, entry] of this.instanceCache.entries()) {
|
|
2870
|
+
const [cachedSchemaName] = nameKey.split(':');
|
|
2871
|
+
if (cachedSchemaName === schemaName) {
|
|
2872
|
+
const context = entry.instance._getSnapshotContext();
|
|
2873
|
+
const id = context.id;
|
|
2874
|
+
if (id && !seen.has(id)) {
|
|
2875
|
+
instances.push(entry.instance);
|
|
2876
|
+
seen.add(id);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
if (waitForReady && instances.length > 0) {
|
|
2882
|
+
await Promise.all(instances.map((m) => waitForEntityIdle(m, {
|
|
2883
|
+
timeout: readyTimeout,
|
|
2884
|
+
throwOnError: false,
|
|
2885
|
+
})));
|
|
2886
|
+
return instances.filter((m) => m._getSnapshot?.().value === 'idle');
|
|
2887
|
+
}
|
|
2888
|
+
return instances;
|
|
2889
|
+
}
|
|
2890
|
+
/**
|
|
2891
|
+
* Update name index when model name changes
|
|
2892
|
+
*/
|
|
2893
|
+
static updateNameIndex(oldName, newName, schemaName, modelFileId) {
|
|
2894
|
+
const oldKey = `${schemaName}:${oldName}`;
|
|
2895
|
+
const newKey = `${schemaName}:${newName}`;
|
|
2896
|
+
// Update name index
|
|
2897
|
+
if (this.instanceCacheByName.has(oldKey)) {
|
|
2898
|
+
this.instanceCacheByName.delete(oldKey);
|
|
2899
|
+
}
|
|
2900
|
+
this.instanceCacheByName.set(newKey, modelFileId);
|
|
2901
|
+
// Also update legacy cache if it exists
|
|
2902
|
+
if (this.instanceCache.has(oldKey)) {
|
|
2903
|
+
const entry = this.instanceCache.get(oldKey);
|
|
2904
|
+
this.instanceCache.delete(oldKey);
|
|
2905
|
+
this.instanceCache.set(newKey, entry);
|
|
2906
|
+
}
|
|
2907
|
+
logger(`Model.updateNameIndex: Updated name mapping from "${oldName}" to "${newName}" (ID: ${modelFileId})`);
|
|
2908
|
+
}
|
|
2909
|
+
getService() {
|
|
2910
|
+
return this._service;
|
|
2911
|
+
}
|
|
2912
|
+
_getSnapshot() {
|
|
2913
|
+
return this._service.getSnapshot();
|
|
2914
|
+
}
|
|
2915
|
+
_getSnapshotContext() {
|
|
2916
|
+
return this._getSnapshot().context;
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Check for conflicts between actor context and database
|
|
2920
|
+
* @returns ConflictResult indicating if a conflict exists
|
|
2921
|
+
*/
|
|
2922
|
+
async _checkForConflicts() {
|
|
2923
|
+
const context = this._getSnapshotContext();
|
|
2924
|
+
// If we don't have load metadata, can't check for conflicts
|
|
2925
|
+
if (!context._dbUpdatedAt || !context.id) {
|
|
2926
|
+
return { hasConflict: false };
|
|
2927
|
+
}
|
|
2928
|
+
try {
|
|
2929
|
+
const db = BaseDb.getAppDb();
|
|
2930
|
+
if (!db) {
|
|
2931
|
+
return { hasConflict: false }; // Can't check without DB
|
|
2932
|
+
}
|
|
2933
|
+
// Get current DB record by id (schemaFileId) - most reliable lookup
|
|
2934
|
+
const dbModels = await db
|
|
2935
|
+
.select()
|
|
2936
|
+
.from(models)
|
|
2937
|
+
.where(eq(models.schemaFileId, context.id)) // id is now the schemaFileId (string)
|
|
2938
|
+
.limit(1);
|
|
2939
|
+
if (dbModels.length === 0) {
|
|
2940
|
+
return { hasConflict: false }; // No DB record, no conflict
|
|
2941
|
+
}
|
|
2942
|
+
const dbRecord = dbModels[0];
|
|
2943
|
+
// Check if DB was updated after we loaded
|
|
2944
|
+
// Note: models table doesn't have updatedAt, so we can't do timestamp comparison
|
|
2945
|
+
// For now, we'll just check if the record exists and matches
|
|
2946
|
+
// In the future, if models table gets updatedAt, we can compare timestamps
|
|
2947
|
+
return { hasConflict: false };
|
|
2948
|
+
}
|
|
2949
|
+
catch (error) {
|
|
2950
|
+
logger(`Error checking for conflicts: ${error instanceof Error ? error.message : String(error)}`);
|
|
2951
|
+
return { hasConflict: false }; // On error, assume no conflict to allow save
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* Saves model name changes to the database immediately (draft save)
|
|
2956
|
+
* This is called when modelName is changed to persist the change immediately
|
|
2957
|
+
* @param oldName - The old model name (if name changed)
|
|
2958
|
+
* @param newName - The new model name
|
|
2959
|
+
*/
|
|
2960
|
+
async _saveDraftToDb(oldName, newName) {
|
|
2961
|
+
saveDraftLogger(`_saveDraftToDb called for model (oldName: "${oldName}", newName: "${newName}")`);
|
|
2962
|
+
// Don't save during initialization - models are being loaded from files, not created as drafts
|
|
2963
|
+
// Check this FIRST before doing any expensive work like _getSnapshotContext()
|
|
2964
|
+
if (typeof window !== 'undefined') {
|
|
2965
|
+
const now = Date.now();
|
|
2966
|
+
// Use cached check to avoid expensive operations
|
|
2967
|
+
// Always check if cache is stale or if we previously got false (to allow recovery)
|
|
2968
|
+
const cacheIsStale = Model.cachedClientInitialized === null || (now - Model.clientCheckTime) > Model.CLIENT_CHECK_CACHE_MS;
|
|
2969
|
+
const shouldRecheck = cacheIsStale || Model.cachedClientInitialized === false;
|
|
2970
|
+
saveDraftLogger(`Client check: cacheIsStale=${cacheIsStale}, shouldRecheck=${shouldRecheck}, cachedValue=${Model.cachedClientInitialized}, timeSinceCheck=${now - Model.clientCheckTime}ms`);
|
|
2971
|
+
if (shouldRecheck) {
|
|
2972
|
+
try {
|
|
2973
|
+
// Use dynamic import for browser compatibility (require() doesn't work in browsers)
|
|
2974
|
+
const client = getClient();
|
|
2975
|
+
const clientSnapshot = client.getService().getSnapshot();
|
|
2976
|
+
// Check if state is IDLE (primary check) - isInitialized is set in entry action so should be true
|
|
2977
|
+
// But we check it as a secondary safeguard
|
|
2978
|
+
const isIdle = clientSnapshot.value === ClientManagerState.IDLE;
|
|
2979
|
+
const isInitialized = clientSnapshot.context.isInitialized;
|
|
2980
|
+
// If state is IDLE, trust it even if isInitialized isn't set yet (entry action should set it)
|
|
2981
|
+
// This aligns with useIsClientReady which only checks the state value
|
|
2982
|
+
Model.cachedClientInitialized = isIdle && (isInitialized !== false);
|
|
2983
|
+
Model.clientCheckTime = now;
|
|
2984
|
+
saveDraftLogger(`Client state checked: state=${clientSnapshot.value}, isIdle=${isIdle}, isInitialized=${isInitialized}, result=${Model.cachedClientInitialized}`);
|
|
2985
|
+
}
|
|
2986
|
+
catch (error) {
|
|
2987
|
+
// If we can't check client state, assume not initialized to be safe
|
|
2988
|
+
// But only cache for a short time to allow recovery
|
|
2989
|
+
Model.cachedClientInitialized = false;
|
|
2990
|
+
Model.clientCheckTime = now;
|
|
2991
|
+
saveDraftLogger(`Error checking client state: ${error instanceof Error ? error.message : String(error)}, setting cachedClientInitialized=false`);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
else {
|
|
2995
|
+
saveDraftLogger(`Using cached client state: cachedClientInitialized=${Model.cachedClientInitialized}`);
|
|
2996
|
+
}
|
|
2997
|
+
if (!Model.cachedClientInitialized) {
|
|
2998
|
+
saveDraftLogger(`Client not initialized, skipping save (oldName: "${oldName}", newName: "${newName}")`);
|
|
2999
|
+
// Skip silently during initialization to avoid log spam and reduce overhead
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
saveDraftLogger(`Client is initialized, proceeding with save (oldName: "${oldName}", newName: "${newName}")`);
|
|
3004
|
+
// Check for conflicts before saving
|
|
3005
|
+
const conflictCheck = await this._checkForConflicts();
|
|
3006
|
+
if (conflictCheck.hasConflict) {
|
|
3007
|
+
const errorMessage = conflictCheck.message || 'Database was updated externally. Please reload the model and try again.';
|
|
3008
|
+
saveDraftLogger(`CONFLICT DETECTED: ${errorMessage}`);
|
|
3009
|
+
throw new ConflictError(errorMessage, conflictCheck);
|
|
3010
|
+
}
|
|
3011
|
+
const context = this._getSnapshotContext();
|
|
3012
|
+
const modelFileId = context.id || ''; // id is now the schemaFileId (string)
|
|
3013
|
+
const modelName = newName || context.modelName || oldName || '';
|
|
3014
|
+
// Use modelFileId as the key if available, otherwise use schemaName:modelName
|
|
3015
|
+
const saveKey = modelFileId || `${context.schemaName}:${modelName}`;
|
|
3016
|
+
// Prevent concurrent saves for the same model
|
|
3017
|
+
if (Model.savingModels.has(saveKey)) {
|
|
3018
|
+
saveDraftLogger(`Model ${modelName} (key: ${saveKey}) is already being saved, skipping concurrent save`);
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
Model.savingModels.add(saveKey);
|
|
3022
|
+
saveDraftLogger(`Starting save for model ${modelName} (key: ${saveKey}, id: ${modelFileId})`);
|
|
3023
|
+
try {
|
|
3024
|
+
const db = BaseDb.getAppDb();
|
|
3025
|
+
if (!db) {
|
|
3026
|
+
throw new Error('Database not found');
|
|
3027
|
+
}
|
|
3028
|
+
// If name changed, use renameModelInDb
|
|
3029
|
+
if (oldName && newName && oldName !== newName) {
|
|
3030
|
+
saveDraftLogger(`Renaming model from "${oldName}" to "${newName}"`);
|
|
3031
|
+
await renameModelInDb(oldName, newName, context.schemaName);
|
|
3032
|
+
saveDraftLogger(`Successfully renamed model from "${oldName}" to "${newName}"`);
|
|
3033
|
+
}
|
|
3034
|
+
else {
|
|
3035
|
+
// For other tracked properties, we would save here
|
|
3036
|
+
// Currently only modelName is saved immediately, so this is a no-op
|
|
3037
|
+
saveDraftLogger(`No name change detected, skipping save (modelName: "${modelName}")`);
|
|
3038
|
+
}
|
|
3039
|
+
// Update conflict detection metadata after successful save
|
|
3040
|
+
// Note: models table doesn't have updatedAt, so we use current timestamp
|
|
3041
|
+
const now = Date.now();
|
|
3042
|
+
this._service.send({
|
|
3043
|
+
type: 'updateContext',
|
|
3044
|
+
_dbUpdatedAt: now,
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
catch (error) {
|
|
3048
|
+
logger(`Failed to save draft to database: ${error instanceof Error ? error.message : String(error)}`);
|
|
3049
|
+
// Don't throw - allow operation to continue
|
|
3050
|
+
}
|
|
3051
|
+
finally {
|
|
3052
|
+
// Always remove from saving set
|
|
3053
|
+
Model.savingModels.delete(saveKey);
|
|
3054
|
+
saveDraftLogger(`Finished save for model ${modelName} (key: ${saveKey})`);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
get status() {
|
|
3058
|
+
return this._getSnapshot().value;
|
|
3059
|
+
}
|
|
3060
|
+
get validationErrors() {
|
|
3061
|
+
return this._getSnapshotContext()._validationErrors || [];
|
|
3062
|
+
}
|
|
3063
|
+
get isValid() {
|
|
3064
|
+
const errors = this.validationErrors;
|
|
3065
|
+
return errors.length === 0;
|
|
3066
|
+
}
|
|
3067
|
+
get isEdited() {
|
|
3068
|
+
const context = this._getSnapshotContext();
|
|
3069
|
+
// First check in-memory state
|
|
3070
|
+
if (context._isEdited !== undefined) {
|
|
3071
|
+
return context._isEdited;
|
|
3072
|
+
}
|
|
3073
|
+
// Fall back to reading from database if we have _dbId
|
|
3074
|
+
if (context._dbId) {
|
|
3075
|
+
// Load from database asynchronously (fire-and-forget for now)
|
|
3076
|
+
// For synchronous getter, we'll need to cache it or make it async
|
|
3077
|
+
// For now, return false if not in context
|
|
3078
|
+
return false;
|
|
3079
|
+
}
|
|
3080
|
+
return false;
|
|
3081
|
+
}
|
|
3082
|
+
get id() {
|
|
3083
|
+
return this._getSnapshotContext().id; // id is now the schemaFileId (string)
|
|
3084
|
+
}
|
|
3085
|
+
get name() {
|
|
3086
|
+
return this._getSnapshotContext().modelName;
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Returns ModelProperty instances for this model
|
|
3090
|
+
* This is a computed property that reads from the service context
|
|
3091
|
+
* Note: This is NOT reactive - use useModelProperties() hook for reactivity
|
|
3092
|
+
*/
|
|
3093
|
+
get properties() {
|
|
3094
|
+
const snapshot = this._service.getSnapshot();
|
|
3095
|
+
const context = snapshot.context;
|
|
3096
|
+
// Get property IDs from liveQuery (stored in context, not instanceState)
|
|
3097
|
+
const liveQueryIds = context._liveQueryPropertyIds || [];
|
|
3098
|
+
// Get _dbId from context for pending writes lookup
|
|
3099
|
+
const dbId = context._dbId; // _dbId is the database integer ID
|
|
3100
|
+
// Get pending property IDs (synchronous - uses static Map)
|
|
3101
|
+
// Lazy import ModelProperty to avoid circular dependency
|
|
3102
|
+
const ModelProperty = getModelProperty();
|
|
3103
|
+
if (!ModelProperty) {
|
|
3104
|
+
// If ModelProperty is not yet loaded, return empty array
|
|
3105
|
+
// It will be available on subsequent accesses after the async import completes
|
|
3106
|
+
return [];
|
|
3107
|
+
}
|
|
3108
|
+
const pendingIds = dbId ? ModelProperty.getPendingPropertyIds(dbId) : [];
|
|
3109
|
+
// Combine and deduplicate
|
|
3110
|
+
const allPropertyIds = [...new Set([...liveQueryIds, ...pendingIds])];
|
|
3111
|
+
// Get ModelProperty instances from static cache (synchronous)
|
|
3112
|
+
const propertyInstances = [];
|
|
3113
|
+
for (const propertyFileId of allPropertyIds) {
|
|
3114
|
+
const property = ModelProperty.getById(propertyFileId);
|
|
3115
|
+
if (property) {
|
|
3116
|
+
propertyInstances.push(property);
|
|
3117
|
+
}
|
|
3118
|
+
// Note: Cannot create properties asynchronously in this synchronous getter
|
|
3119
|
+
// Properties will be created elsewhere when needed
|
|
3120
|
+
}
|
|
3121
|
+
// Return a new array reference (snapshot at time of access)
|
|
3122
|
+
return [...propertyInstances];
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* Validate the model
|
|
3126
|
+
* @returns Validation result
|
|
3127
|
+
*/
|
|
3128
|
+
async validate() {
|
|
3129
|
+
return new Promise((resolve) => {
|
|
3130
|
+
let resolved = false;
|
|
3131
|
+
const subscription = this._service.subscribe((snapshot) => {
|
|
3132
|
+
if (snapshot.value === 'idle' || snapshot.value === 'error') {
|
|
3133
|
+
if (!resolved) {
|
|
3134
|
+
resolved = true;
|
|
3135
|
+
subscription.unsubscribe();
|
|
3136
|
+
const errors = snapshot.context._validationErrors || [];
|
|
3137
|
+
resolve({
|
|
3138
|
+
isValid: errors.length === 0,
|
|
3139
|
+
errors,
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
});
|
|
3144
|
+
this._service.send({ type: 'validateModel' });
|
|
3145
|
+
// Timeout fallback to ensure we always resolve (15 seconds - longer than actor timeout)
|
|
3146
|
+
setTimeout(() => {
|
|
3147
|
+
if (!resolved) {
|
|
3148
|
+
resolved = true;
|
|
3149
|
+
subscription.unsubscribe();
|
|
3150
|
+
const errors = this._getSnapshotContext()._validationErrors || [];
|
|
3151
|
+
resolve({
|
|
3152
|
+
isValid: errors.length === 0,
|
|
3153
|
+
errors,
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
}, 15000);
|
|
3157
|
+
});
|
|
3158
|
+
}
|
|
3159
|
+
/**
|
|
3160
|
+
* Create a new item instance from this model
|
|
3161
|
+
* Automatically injects the model name, so you don't need to pass it explicitly.
|
|
3162
|
+
*
|
|
3163
|
+
* @example
|
|
3164
|
+
* const Post = Model.create('Post', schema)
|
|
3165
|
+
* const post = await Post.create({ title: 'My Post', content: '...' })
|
|
3166
|
+
*
|
|
3167
|
+
* @param values - Item property values (modelName is automatically injected)
|
|
3168
|
+
* @returns The created item instance
|
|
3169
|
+
*/
|
|
3170
|
+
async create(values) {
|
|
3171
|
+
if (!this.modelName) {
|
|
3172
|
+
throw new Error('Cannot create item: model name is not set on this Model instance');
|
|
3173
|
+
}
|
|
3174
|
+
const item = await Item.create({
|
|
3175
|
+
modelName: this.modelName,
|
|
3176
|
+
schemaName: this.schemaName,
|
|
3177
|
+
modelInstance: this,
|
|
3178
|
+
...values,
|
|
3179
|
+
});
|
|
3180
|
+
return item;
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
3183
|
+
* Reload model from database
|
|
3184
|
+
* This refreshes the actor context with the latest data from the database
|
|
3185
|
+
*/
|
|
3186
|
+
async reload() {
|
|
3187
|
+
logger(`Reloading model ${this.modelName} from database`);
|
|
3188
|
+
// Send reload event to machine (which will trigger loadOrCreateModel)
|
|
3189
|
+
this._service.send({ type: 'reloadFromDb' });
|
|
3190
|
+
// Wait for reload to complete
|
|
3191
|
+
return new Promise((resolve, reject) => {
|
|
3192
|
+
const subscription = this._service.subscribe((snapshot) => {
|
|
3193
|
+
if (snapshot.value === 'idle') {
|
|
3194
|
+
subscription.unsubscribe();
|
|
3195
|
+
resolve();
|
|
3196
|
+
}
|
|
3197
|
+
else if (snapshot.value === 'error') {
|
|
3198
|
+
subscription.unsubscribe();
|
|
3199
|
+
reject(new Error('Failed to reload model from database'));
|
|
3200
|
+
}
|
|
3201
|
+
});
|
|
3202
|
+
// Timeout after 10 seconds
|
|
3203
|
+
setTimeout(() => {
|
|
3204
|
+
subscription.unsubscribe();
|
|
3205
|
+
reject(new Error('Timeout waiting for model reload'));
|
|
3206
|
+
}, 10000);
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Unload the model instance and clean up resources
|
|
3211
|
+
*/
|
|
3212
|
+
unload() {
|
|
3213
|
+
try {
|
|
3214
|
+
const context = this._getSnapshotContext();
|
|
3215
|
+
const modelFileId = context.id; // id is now the schemaFileId (string)
|
|
3216
|
+
const nameKey = `${context.schemaName}:${context.modelName}`;
|
|
3217
|
+
const cacheKeys = [];
|
|
3218
|
+
if (modelFileId) {
|
|
3219
|
+
cacheKeys.push(modelFileId);
|
|
3220
|
+
}
|
|
3221
|
+
cacheKeys.push(nameKey);
|
|
3222
|
+
unloadEntity(this, {
|
|
3223
|
+
getCacheKeys: () => cacheKeys,
|
|
3224
|
+
caches: [Model.instanceCacheById, Model.instanceCache],
|
|
3225
|
+
secondaryCaches: [Model.instanceCacheByName],
|
|
3226
|
+
instanceState: modelInstanceState,
|
|
3227
|
+
getService: (instance) => instance._service,
|
|
3228
|
+
});
|
|
3229
|
+
}
|
|
3230
|
+
catch (error) {
|
|
3231
|
+
logger(`Error during unload: ${error instanceof Error ? error.message : String(error)}`);
|
|
3232
|
+
// Still try to stop service
|
|
3233
|
+
try {
|
|
3234
|
+
this._service.stop();
|
|
3235
|
+
}
|
|
3236
|
+
catch {
|
|
3237
|
+
// Service might already be stopped
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
/**
|
|
3242
|
+
* Destroy the model instance completely: remove from caches, delete from database,
|
|
3243
|
+
* update Schema context, stop service. Uses shared destroy helpers.
|
|
3244
|
+
*/
|
|
3245
|
+
async destroy() {
|
|
3246
|
+
const context = this._getSnapshotContext();
|
|
3247
|
+
const modelFileId = context.id;
|
|
3248
|
+
const modelName = context.modelName;
|
|
3249
|
+
const schemaName = context.schemaName;
|
|
3250
|
+
const nameKey = schemaName && modelName ? `${schemaName}:${modelName}` : '';
|
|
3251
|
+
clearDestroySubscriptions(this, { instanceState: modelInstanceState });
|
|
3252
|
+
const cacheKeys = [];
|
|
3253
|
+
if (modelFileId)
|
|
3254
|
+
cacheKeys.push(modelFileId);
|
|
3255
|
+
if (nameKey)
|
|
3256
|
+
cacheKeys.push(nameKey);
|
|
3257
|
+
forceRemoveFromCaches(this, {
|
|
3258
|
+
getCacheKeys: () => cacheKeys,
|
|
3259
|
+
caches: [
|
|
3260
|
+
Model.instanceCacheById,
|
|
3261
|
+
Model.instanceCacheByName,
|
|
3262
|
+
Model.instanceCache,
|
|
3263
|
+
],
|
|
3264
|
+
});
|
|
3265
|
+
await runDestroyLifecycle(this, {
|
|
3266
|
+
getService: (instance) => instance._service,
|
|
3267
|
+
doDestroy: async () => {
|
|
3268
|
+
const db = BaseDb.getAppDb();
|
|
3269
|
+
if (db && modelFileId) {
|
|
3270
|
+
const drizzleMod = await import('drizzle-orm');
|
|
3271
|
+
const { eq } = drizzleMod;
|
|
3272
|
+
const modelRecords = await db
|
|
3273
|
+
.select()
|
|
3274
|
+
.from(models)
|
|
3275
|
+
.where(eq(models.schemaFileId, modelFileId))
|
|
3276
|
+
.limit(1);
|
|
3277
|
+
if (modelRecords.length > 0) {
|
|
3278
|
+
const modelId = modelRecords[0].id;
|
|
3279
|
+
await db.delete(modelSchemas).where(eq(modelSchemas.modelId, modelId));
|
|
3280
|
+
await db
|
|
3281
|
+
.delete(properties)
|
|
3282
|
+
.where(eq(properties.modelId, typeof modelId === 'string' ? parseInt(modelId, 10) : modelId));
|
|
3283
|
+
await db.delete(models).where(eq(models.id, modelId));
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
if (schemaName && modelName) {
|
|
3287
|
+
const Schema = await schemaImportPromise;
|
|
3288
|
+
if (!Schema)
|
|
3289
|
+
throw new Error('Schema not loaded');
|
|
3290
|
+
const schema = Schema.create(schemaName, { waitForReady: false });
|
|
3291
|
+
const schemaContext = schema.getService().getSnapshot().context;
|
|
3292
|
+
if (schemaContext.models?.[modelName]) {
|
|
3293
|
+
const updatedModels = { ...schemaContext.models };
|
|
3294
|
+
delete updatedModels[modelName];
|
|
3295
|
+
schema.getService().send({ type: 'updateContext', models: updatedModels });
|
|
3296
|
+
schema.getService().send({ type: 'markAsDraft', propertyKey: `model:${modelName}` });
|
|
3297
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
},
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* Set up liveQuery subscription to watch for property changes in the database
|
|
3305
|
+
* This enables cross-instance synchronization (e.g., changes in other tabs/windows)
|
|
3306
|
+
*/
|
|
3307
|
+
_setupLiveQuerySubscription() {
|
|
3308
|
+
setupEntityLiveQuery(this, {
|
|
3309
|
+
getEntityId: async (model) => {
|
|
3310
|
+
const context = model._getSnapshotContext();
|
|
3311
|
+
let dbId = context._dbId; // _dbId is the database integer ID
|
|
3312
|
+
const modelName = context.modelName;
|
|
3313
|
+
if (!modelName) {
|
|
3314
|
+
return undefined;
|
|
3315
|
+
}
|
|
3316
|
+
// If we don't have _dbId yet, try to find it in the database
|
|
3317
|
+
if (!dbId) {
|
|
3318
|
+
try {
|
|
3319
|
+
const db = BaseDb.getAppDb();
|
|
3320
|
+
if (!db) {
|
|
3321
|
+
return undefined;
|
|
3322
|
+
}
|
|
3323
|
+
const modelRecords = await db
|
|
3324
|
+
.select()
|
|
3325
|
+
.from(models)
|
|
3326
|
+
.where(eq(models.name, modelName))
|
|
3327
|
+
.limit(1);
|
|
3328
|
+
if (modelRecords.length === 0 || !modelRecords[0].id) {
|
|
3329
|
+
return undefined;
|
|
3330
|
+
}
|
|
3331
|
+
dbId = modelRecords[0].id;
|
|
3332
|
+
// Update context with _dbId
|
|
3333
|
+
model._service.send({
|
|
3334
|
+
type: 'updateContext',
|
|
3335
|
+
_dbId: dbId,
|
|
3336
|
+
});
|
|
3337
|
+
}
|
|
3338
|
+
catch (error) {
|
|
3339
|
+
logger(`[Model._setupLiveQuerySubscription] Error finding model: ${error}`);
|
|
3340
|
+
return undefined;
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return dbId;
|
|
3344
|
+
},
|
|
3345
|
+
buildQuery: async (modelId) => {
|
|
3346
|
+
return BaseDb.liveQuery((sql) => sql `
|
|
3347
|
+
SELECT id, name, data_type as dataType, model_id as modelId, ref_model_id as refModelId, ref_value_type as refValueType, schema_file_id as schemaFileId
|
|
3348
|
+
FROM properties
|
|
3349
|
+
WHERE model_id = ${modelId}
|
|
3350
|
+
`);
|
|
3351
|
+
},
|
|
3352
|
+
extractEntityIds: (rows) => rows
|
|
3353
|
+
.map(row => row.schemaFileId)
|
|
3354
|
+
.filter((id) => id !== null && id !== undefined),
|
|
3355
|
+
updateContext: (model, ids) => {
|
|
3356
|
+
model._service.send({
|
|
3357
|
+
type: 'updateContext',
|
|
3358
|
+
_liveQueryPropertyIds: ids, // Store in service context for reactivity
|
|
3359
|
+
_propertiesUpdated: Date.now(), // Internal field for tracking
|
|
3360
|
+
});
|
|
3361
|
+
},
|
|
3362
|
+
createChildInstances: async (ids) => {
|
|
3363
|
+
await modelPropertyImportPromise;
|
|
3364
|
+
const ModelProperty = getModelProperty();
|
|
3365
|
+
if (!ModelProperty) {
|
|
3366
|
+
logger('[Model._setupLiveQuerySubscription] ModelProperty not yet loaded');
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
const createPromises = ids.map(async (propertyFileId) => {
|
|
3370
|
+
try {
|
|
3371
|
+
await ModelProperty.createById(propertyFileId);
|
|
3372
|
+
}
|
|
3373
|
+
catch (error) {
|
|
3374
|
+
logger(`[Model._setupLiveQuerySubscription] Error creating ModelProperty instance for propertyFileId "${propertyFileId}": ${error}`);
|
|
3375
|
+
}
|
|
3376
|
+
});
|
|
3377
|
+
await Promise.all(createPromises);
|
|
3378
|
+
},
|
|
3379
|
+
queryInitialData: async (modelId) => {
|
|
3380
|
+
const db = BaseDb.getAppDb();
|
|
3381
|
+
if (!db) {
|
|
3382
|
+
return [];
|
|
3383
|
+
}
|
|
3384
|
+
const initialProperties = await db
|
|
3385
|
+
.select({ schemaFileId: properties.schemaFileId })
|
|
3386
|
+
.from(properties)
|
|
3387
|
+
.where(eq(properties.modelId, typeof modelId === 'string' ? parseInt(modelId, 10) : modelId));
|
|
3388
|
+
return initialProperties.map((row) => ({
|
|
3389
|
+
schemaFileId: row.schemaFileId,
|
|
3390
|
+
}));
|
|
3391
|
+
},
|
|
3392
|
+
instanceState: modelInstanceState,
|
|
3393
|
+
loggerName: 'seedSdk:model:liveQuery',
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
/**
|
|
3397
|
+
* Refresh property IDs from database (useful in Node.js where liveQuery isn't available)
|
|
3398
|
+
*/
|
|
3399
|
+
async _refreshPropertiesFromDb() {
|
|
3400
|
+
const snapshot = this._service.getSnapshot();
|
|
3401
|
+
const dbId = snapshot.context._dbId; // _dbId is the database integer ID
|
|
3402
|
+
const modelName = snapshot.context.modelName;
|
|
3403
|
+
if (!dbId || !modelName) {
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
try {
|
|
3407
|
+
const db = BaseDb.getAppDb();
|
|
3408
|
+
if (!db) {
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
const propertyRows = await db
|
|
3412
|
+
.select({ schemaFileId: properties.schemaFileId })
|
|
3413
|
+
.from(properties)
|
|
3414
|
+
.where(eq(properties.modelId, dbId)); // Use _dbId (database integer ID)
|
|
3415
|
+
const propertyIds = propertyRows
|
|
3416
|
+
.map((row) => row.schemaFileId)
|
|
3417
|
+
.filter((id) => id !== null && id !== undefined);
|
|
3418
|
+
// CRITICAL: Create ModelProperty instances BEFORE updating context
|
|
3419
|
+
// This ensures they're in the cache when the properties getter is called
|
|
3420
|
+
if (propertyIds.length > 0) {
|
|
3421
|
+
try {
|
|
3422
|
+
await modelPropertyImportPromise;
|
|
3423
|
+
const ModelProperty = getModelProperty();
|
|
3424
|
+
if (!ModelProperty) {
|
|
3425
|
+
logger('[Model._refreshPropertiesFromDb] ModelProperty not yet loaded');
|
|
3426
|
+
}
|
|
3427
|
+
else {
|
|
3428
|
+
const createPromises = propertyIds.map(async (propertyFileId) => {
|
|
3429
|
+
try {
|
|
3430
|
+
const property = await ModelProperty.createById(propertyFileId);
|
|
3431
|
+
if (property) {
|
|
3432
|
+
logger(`[Model._refreshPropertiesFromDb] Created/cached ModelProperty instance for propertyFileId "${propertyFileId}"`);
|
|
3433
|
+
}
|
|
3434
|
+
else {
|
|
3435
|
+
logger(`[Model._refreshPropertiesFromDb] ModelProperty.createById returned undefined for propertyFileId "${propertyFileId}"`);
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
catch (error) {
|
|
3439
|
+
logger(`[Model._refreshPropertiesFromDb] Error creating ModelProperty instance for propertyFileId "${propertyFileId}": ${error}`);
|
|
3440
|
+
}
|
|
3441
|
+
});
|
|
3442
|
+
await Promise.all(createPromises);
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
catch (error) {
|
|
3446
|
+
logger(`[Model._refreshPropertiesFromDb] Error importing ModelProperty or creating instances: ${error}`);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
// Update context with refreshed property IDs AFTER creating instances
|
|
3450
|
+
this._service.send({
|
|
3451
|
+
type: 'updateContext',
|
|
3452
|
+
_liveQueryPropertyIds: propertyIds,
|
|
3453
|
+
_propertiesUpdated: Date.now(),
|
|
3454
|
+
});
|
|
3455
|
+
}
|
|
3456
|
+
catch (error) {
|
|
3457
|
+
logger(`[Model._refreshPropertiesFromDb] Error refreshing properties: ${error}`);
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
/**
|
|
3461
|
+
* Public method to refresh properties from database
|
|
3462
|
+
*/
|
|
3463
|
+
async refreshProperties() {
|
|
3464
|
+
await this._refreshPropertiesFromDb();
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
// Primary cache: ID-based (never changes on rename)
|
|
3468
|
+
Model.instanceCacheById = new Map();
|
|
3469
|
+
// Secondary index: name lookup (updated on rename)
|
|
3470
|
+
Model.instanceCacheByName = new Map();
|
|
3471
|
+
// Legacy cache: kept temporarily for backward compatibility during migration
|
|
3472
|
+
Model.instanceCache = new Map();
|
|
3473
|
+
// Static tracking for pending writes
|
|
3474
|
+
Model.pendingWrites = new Map();
|
|
3475
|
+
// Track which models are currently being saved to prevent concurrent saves
|
|
3476
|
+
Model.savingModels = new Set();
|
|
3477
|
+
// Cache client initialization state globally to avoid repeated checks
|
|
3478
|
+
Model.cachedClientInitialized = null;
|
|
3479
|
+
Model.clientCheckTime = 0;
|
|
3480
|
+
Model.CLIENT_CHECK_CACHE_MS = 50; // Cache for 50ms to avoid excessive checks
|
|
3481
|
+
|
|
3482
|
+
var Model$1 = /*#__PURE__*/Object.freeze({
|
|
3483
|
+
__proto__: null,
|
|
3484
|
+
Model: Model
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
// Export the Model class
|
|
3488
|
+
const TModelValues = Type.Record(Type.String(), Type.Any());
|
|
3489
|
+
const TModelSchema = Type.Record(Type.String(), TProperty);
|
|
3490
|
+
|
|
3491
|
+
export { ConflictError as C, Model as M, TModelSchema as T, TModelValues as a, Model$1 as b, setupEntityLiveQuery as s, writeProcessMachine as w };
|
|
3492
|
+
//# sourceMappingURL=index-BFpbtEsg.js.map
|