@statezero/core 0.1.93 → 0.1.96

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.
@@ -29,6 +29,7 @@ export namespace EventType {
29
29
  let CREATE: string;
30
30
  let UPDATE: string;
31
31
  let DELETE: string;
32
+ let BULK_CREATE: string;
32
33
  let BULK_UPDATE: string;
33
34
  let BULK_DELETE: string;
34
35
  }
@@ -22,6 +22,7 @@ export const EventType = {
22
22
  CREATE: "create",
23
23
  UPDATE: "update",
24
24
  DELETE: "delete",
25
+ BULK_CREATE: "bulk_create",
25
26
  BULK_UPDATE: "bulk_update",
26
27
  BULK_DELETE: "bulk_delete",
27
28
  };
@@ -80,7 +80,7 @@ export async function makeApiCall(querySet, operationType, args = {}, operationI
80
80
  }
81
81
  // Determine if this is a write operation that needs FileObject processing
82
82
  const writeOperations = [
83
- "create", "update", "delete", "update_instance", "delete_instance",
83
+ "create", "bulk_create", "update", "delete", "update_instance", "delete_instance",
84
84
  "get_or_create", "update_or_create"
85
85
  ];
86
86
  const isWriteOperation = writeOperations.includes(operationType);
@@ -28,13 +28,6 @@ export class Manager {
28
28
  * @returns {QuerySet} A new QuerySet instance for the model.
29
29
  */
30
30
  newQuerySet(): QuerySet<any>;
31
- /**
32
- * Creates a new custom QuerySet instance with an initial name.
33
- *
34
- * @param {string} name - The initial queryset name.
35
- * @returns {QuerySet} A new QuerySet instance for the model.
36
- */
37
- customQueryset(name: string): QuerySet<any>;
38
31
  /**
39
32
  * Retrieves a single model instance matching the provided filters.
40
33
  *
@@ -126,6 +119,13 @@ export class Manager {
126
119
  * @returns {Promise<*>} A promise that resolves to the newly created model instance.
127
120
  */
128
121
  create(data: any): Promise<any>;
122
+ /**
123
+ * Creates multiple model instances using the provided data array.
124
+ *
125
+ * @param {Array<Object>} dataList - Array of data objects to create model instances.
126
+ * @returns {Promise<Array<*>>} A promise that resolves to an array of newly created model instances.
127
+ */
128
+ bulkCreate(dataList: Array<Object>): Promise<Array<any>>;
129
129
  /**
130
130
  * Fetches all records using the current QuerySet.
131
131
  *
@@ -35,17 +35,6 @@ export class Manager {
35
35
  newQuerySet() {
36
36
  return new this.QuerySetClass(this.ModelClass);
37
37
  }
38
- /**
39
- * Creates a new custom QuerySet instance with an initial name.
40
- *
41
- * @param {string} name - The initial queryset name.
42
- * @returns {QuerySet} A new QuerySet instance for the model.
43
- */
44
- customQueryset(name) {
45
- return new this.QuerySetClass(this.ModelClass, {
46
- initialQueryset: name
47
- });
48
- }
49
38
  /**
50
39
  * Retrieves a single model instance matching the provided filters.
51
40
  *
@@ -163,6 +152,15 @@ export class Manager {
163
152
  async create(data) {
164
153
  return this.newQuerySet().create(data);
165
154
  }
155
+ /**
156
+ * Creates multiple model instances using the provided data array.
157
+ *
158
+ * @param {Array<Object>} dataList - Array of data objects to create model instances.
159
+ * @returns {Promise<Array<*>>} A promise that resolves to an array of newly created model instances.
160
+ */
161
+ async bulkCreate(dataList) {
162
+ return this.newQuerySet().bulkCreate(dataList);
163
+ }
166
164
  /**
167
165
  * Fetches all records using the current QuerySet.
168
166
  *
@@ -11,6 +11,14 @@ export class OperationFactory {
11
11
  * @returns {Operation} The created operation
12
12
  */
13
13
  static createCreateOperation(queryset: QuerySet, data?: Object, operationId?: string): Operation;
14
+ /**
15
+ * Create a BULK_CREATE operation
16
+ * @param {QuerySet} queryset - The queryset context
17
+ * @param {Array<Object>} dataList - Array of data objects for the new instances
18
+ * @param {string} [operationId] - Optional operation ID (for hotpath events)
19
+ * @returns {Operation} The created operation
20
+ */
21
+ static createBulkCreateOperation(queryset: QuerySet, dataList?: Array<Object>, operationId?: string): Operation;
14
22
  /**
15
23
  * Create an UPDATE operation with optimistic instance updates
16
24
  * @param {QuerySet} queryset - The queryset context
@@ -31,6 +31,30 @@ export class OperationFactory {
31
31
  args: { data },
32
32
  });
33
33
  }
34
+ /**
35
+ * Create a BULK_CREATE operation
36
+ * @param {QuerySet} queryset - The queryset context
37
+ * @param {Array<Object>} dataList - Array of data objects for the new instances
38
+ * @param {string} [operationId] - Optional operation ID (for hotpath events)
39
+ * @returns {Operation} The created operation
40
+ */
41
+ static createBulkCreateOperation(queryset, dataList = [], operationId = null) {
42
+ const ModelClass = queryset.ModelClass;
43
+ const primaryKeyField = ModelClass.primaryKeyField;
44
+ const opId = operationId || `${uuid7()}`;
45
+ // Create temp PKs for each instance
46
+ const instances = dataList.map((data, index) => {
47
+ const tempPk = createTempPk(`${opId}_${index}`);
48
+ return { ...data, [primaryKeyField]: tempPk };
49
+ });
50
+ return new Operation({
51
+ operationId: opId,
52
+ type: Type.BULK_CREATE,
53
+ instances: instances,
54
+ queryset: queryset,
55
+ args: { data: dataList },
56
+ });
57
+ }
34
58
  /**
35
59
  * Create an UPDATE operation with optimistic instance updates
36
60
  * @param {QuerySet} queryset - The queryset context
@@ -101,6 +101,15 @@ export class QueryExecutor {
101
101
  * @returns {LiveThenable<Object>} The live Model instance which resolves to the created model.
102
102
  */
103
103
  static executeCreate(querySet: QuerySet, operationType?: string, args?: Object): LiveThenable<Object>;
104
+ /**
105
+ * Executes a bulk_create operation with the QuerySet.
106
+ *
107
+ * @param {QuerySet} querySet - The QuerySet to execute.
108
+ * @param {string} operationType - The operation type (always 'bulk_create' for this method).
109
+ * @param {Object} args - Additional arguments for the operation.
110
+ * @returns {LiveThenable<Array>} Array of live Model instances.
111
+ */
112
+ static executeBulkCreate(querySet: QuerySet, operationType?: string, args?: Object): LiveThenable<any[]>;
104
113
  /**
105
114
  * Executes an update_instance operation with the QuerySet.
106
115
  *
@@ -333,6 +333,75 @@ export class QueryExecutor {
333
333
  // 3) return the live‑thenable
334
334
  return makeLiveThenable(live, promise);
335
335
  }
336
+ /**
337
+ * Executes a bulk_create operation with the QuerySet.
338
+ *
339
+ * @param {QuerySet} querySet - The QuerySet to execute.
340
+ * @param {string} operationType - The operation type (always 'bulk_create' for this method).
341
+ * @param {Object} args - Additional arguments for the operation.
342
+ * @returns {LiveThenable<Array>} Array of live Model instances.
343
+ */
344
+ static executeBulkCreate(querySet, operationType = "bulk_create", args = {}) {
345
+ const ModelClass = querySet.ModelClass;
346
+ const operationId = `${uuid7()}`;
347
+ const primaryKeyField = ModelClass.primaryKeyField;
348
+ const apiCallArgs = {
349
+ data: args.data || [],
350
+ };
351
+ if (isNil(args.data) || !Array.isArray(args.data)) {
352
+ console.warn(`executeBulkCreate was called with invalid data`);
353
+ return Promise.resolve([]);
354
+ }
355
+ // Use factory to create operation
356
+ const operation = OperationFactory.createBulkCreateOperation(querySet, apiCallArgs.data, operationId);
357
+ // Create placeholder instances for each item
358
+ const liveInstances = operation.instances.map(instance => {
359
+ const tempPk = instance[primaryKeyField];
360
+ return ModelClass.fromPk(tempPk, querySet);
361
+ });
362
+ // Kick off the async call
363
+ const promise = makeApiCall(querySet, operationType, apiCallArgs, operationId, async (response) => {
364
+ const { data } = response.data;
365
+ const pks = Array.isArray(data) ? data : [];
366
+ // Set real PKs for all instances
367
+ pks.forEach((pk, index) => {
368
+ const tempPkKey = `${operationId}_${index}`;
369
+ setRealPk(tempPkKey, pk);
370
+ });
371
+ })
372
+ .then((response) => {
373
+ const { data, included, model_name } = response.data;
374
+ // Process included entities
375
+ processIncludedEntities(modelStoreRegistry, included, ModelClass);
376
+ // Get the real PKs
377
+ const pks = Array.isArray(data) ? data : [];
378
+ // Update each live instance with its real PK
379
+ pks.forEach((pk, index) => {
380
+ if (liveInstances[index]) {
381
+ liveInstances[index].pk = pk;
382
+ }
383
+ });
384
+ // Get full entity data for all created instances
385
+ const entityMap = included[model_name] || {};
386
+ const confirmedInstances = pks.map(pk => entityMap[pk]).filter(Boolean);
387
+ // Confirm operation with full entity data
388
+ operation.mutate({
389
+ instances: confirmedInstances,
390
+ status: Status.CONFIRMED,
391
+ });
392
+ // Freeze all live instances
393
+ liveInstances.forEach(instance => breakThenable(instance));
394
+ // Also break the thenable on the array itself
395
+ breakThenable(liveInstances);
396
+ return liveInstances;
397
+ })
398
+ .catch((error) => {
399
+ operation.updateStatus(Status.REJECTED);
400
+ throw error;
401
+ });
402
+ // Return a live-thenable array
403
+ return makeLiveThenable(liveInstances, promise);
404
+ }
336
405
  /**
337
406
  * Executes an update_instance operation with the QuerySet.
338
407
  *
@@ -447,6 +516,8 @@ export class QueryExecutor {
447
516
  return this.executeDelete(querySet, operationType, args);
448
517
  case "create":
449
518
  return this.executeCreate(querySet, operationType, args);
519
+ case "bulk_create":
520
+ return this.executeBulkCreate(querySet, operationType, args);
450
521
  case "get_or_create":
451
522
  case "update_or_create":
452
523
  return this.executeOrCreate(querySet, operationType, args);
@@ -194,6 +194,13 @@ export class QuerySet<T> {
194
194
  * @returns {Promise<any>} The created model instance.
195
195
  */
196
196
  create(data: Object): Promise<any>;
197
+ /**
198
+ * Creates multiple model instances using the provided data array.
199
+ *
200
+ * @param {Array<Object>} dataList - Array of data objects to create model instances.
201
+ * @returns {Promise<Array<any>>} A promise that resolves to an array of newly created model instances.
202
+ */
203
+ bulkCreate(dataList: Array<Object>): Promise<Array<any>>;
197
204
  /**
198
205
  * Updates records in the QuerySet.
199
206
  *
@@ -425,6 +425,26 @@ export class QuerySet {
425
425
  }, this);
426
426
  return QueryExecutor.execute(newQs, "create", { data: serializedData });
427
427
  }
428
+ /**
429
+ * Creates multiple model instances using the provided data array.
430
+ *
431
+ * @param {Array<Object>} dataList - Array of data objects to create model instances.
432
+ * @returns {Promise<Array<any>>} A promise that resolves to an array of newly created model instances.
433
+ */
434
+ async bulkCreate(dataList) {
435
+ this.ensureNotMaterialized();
436
+ if (!Array.isArray(dataList)) {
437
+ throw new Error("bulkCreate expects an array of data objects");
438
+ }
439
+ // Serialize each data object before sending to backend
440
+ const serializedDataList = dataList.map(data => this._serializer.toInternal(data));
441
+ // Materialize for bulk create
442
+ const newQs = new QuerySet(this.ModelClass, {
443
+ ...this._getConfig(),
444
+ materialized: true,
445
+ }, this);
446
+ return QueryExecutor.execute(newQs, "bulk_create", { data: serializedDataList });
447
+ }
428
448
  /**
429
449
  * Updates records in the QuerySet.
430
450
  *
@@ -345,6 +345,7 @@ export class ModelStore {
345
345
  let pk = instance[pkField];
346
346
  switch (operation.type) {
347
347
  case Type.CREATE:
348
+ case Type.BULK_CREATE:
348
349
  if (!currentInstances.has(pk)) {
349
350
  currentInstances.set(pk, instance);
350
351
  }
@@ -9,6 +9,7 @@ export namespace Status {
9
9
  }
10
10
  export namespace Type {
11
11
  let CREATE: string;
12
+ let BULK_CREATE: string;
12
13
  let UPDATE: string;
13
14
  let DELETE: string;
14
15
  let UPDATE_INSTANCE: string;
@@ -25,6 +25,7 @@ export const Status = {
25
25
  };
26
26
  export const Type = {
27
27
  CREATE: 'create',
28
+ BULK_CREATE: 'bulk_create',
28
29
  UPDATE: 'update',
29
30
  DELETE: 'delete',
30
31
  UPDATE_INSTANCE: 'update_instance',
@@ -7,30 +7,26 @@ import { QuerySet } from '../../flavours/django/querySet.js';
7
7
  import { isEqual, isNil } from 'lodash-es';
8
8
  import hash from 'object-hash';
9
9
  /**
10
- * Returns querysets that have the same nodes as any ancestor of the specific queryset
10
+ * Returns querysets that are in root mode (materialized with no materialized parent)
11
+ * Since filtered querysets render by filtering their parent's data, we only need
12
+ * to route operations to root querysets. Filtered children will see the operations
13
+ * when they filter their parent's rendered data.
11
14
  * @param {QuerySet} queryset
12
15
  * @returns {Map<QuerySet, Store>}
13
16
  */
14
- function relatedQuerysets(queryset) {
15
- // Collect ancestor nodes for comparison
16
- let ancestorNodes = [];
17
- let current = queryset;
18
- while (current) {
19
- ancestorNodes.push(current.nodes);
20
- current = current.__parent;
21
- }
17
+ function getRootQuerysets(queryset) {
22
18
  const modelClass = queryset.ModelClass;
23
19
  const result = new Map();
24
- Array.from(querysetStoreRegistry._stores.entries()).forEach(([queryset, store]) => {
20
+ // Route only to querysets that are in root mode
21
+ // Note: _stores is Map<semanticKey, Store>, so we get the queryset from store.queryset
22
+ Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
25
23
  if (store.modelClass !== modelClass)
26
24
  return;
27
- try {
28
- if (ancestorNodes.some(nodes => isEqual(nodes, store.queryset.nodes))) {
29
- result.set(store.queryset, store);
30
- }
31
- }
32
- catch (e) {
33
- console.warn('Error comparing nodes for related querysets', e);
25
+ // Use the graph to determine if this store is in root mode
26
+ const { isRoot, root } = querysetStoreRegistry.querysetStoreGraph.findRoot(store.queryset);
27
+ // A queryset is in root mode if isRoot=true and root is its own semantic key
28
+ if (isRoot && root === store.queryset.semanticKey) {
29
+ result.set(store.queryset, store);
34
30
  }
35
31
  });
36
32
  return result;
@@ -92,10 +88,11 @@ function processQuerysetStores(operation, actionType) {
92
88
  // Different routing strategies based on operation type
93
89
  switch (operation.type) {
94
90
  case Type.CREATE:
91
+ case Type.BULK_CREATE:
95
92
  case Type.GET_OR_CREATE:
96
93
  case Type.UPDATE_OR_CREATE:
97
- // For creates, route to related querysets (they might want to include the new item)
98
- querysetStoreMap = relatedQuerysets(queryset);
94
+ // For creates, route to root querysets (they might want to include the new item)
95
+ querysetStoreMap = getRootQuerysets(queryset);
99
96
  break;
100
97
  case Type.UPDATE:
101
98
  case Type.UPDATE_INSTANCE:
@@ -109,32 +106,34 @@ function processQuerysetStores(operation, actionType) {
109
106
  querysetStoreMap = new Map();
110
107
  break;
111
108
  default:
112
- // For other operation types, use the existing related querysets logic
113
- querysetStoreMap = relatedQuerysets(queryset);
109
+ // For other operation types, use the existing root querysets logic
110
+ querysetStoreMap = getRootQuerysets(queryset);
114
111
  break;
115
112
  }
116
113
  Array.from(querysetStoreMap.values()).forEach(applyAction);
117
114
  }
118
115
  /**
119
- * Process an operation in the metric stores based on operation type
116
+ * Process an operation in the metric stores
117
+ *
118
+ * For metrics, we route operations UP the family tree - any metric on an ancestor
119
+ * queryset should receive the operation so it can check if it affects the metric.
120
120
  *
121
121
  * @param {Operation} operation - The operation to process
122
122
  * @param {string} actionType - The action to perform ('add', 'update', 'confirm', 'reject')
123
123
  */
124
124
  function processMetricStores(operation, actionType) {
125
125
  const queryset = operation.queryset;
126
- const ModelClass = queryset.ModelClass;
127
- // For metrics, we can use a similar strategy but might be more conservative
128
- // and always use related querysets since metrics are aggregations
129
- const allQuerysets = Array.from(relatedQuerysets(queryset).keys());
130
- let allMetricStores = new Set();
131
- allQuerysets.forEach(qs => {
132
- const stores = metricRegistry.getAllStoresForQueryset(qs);
126
+ const allMetricStores = new Set();
127
+ // Walk up the queryset family tree and collect all metrics
128
+ let current = queryset;
129
+ while (current) {
130
+ const stores = metricRegistry.getAllStoresForQueryset(current);
133
131
  if (stores && stores.length > 0) {
134
132
  stores.forEach(store => allMetricStores.add(store));
135
133
  }
136
- });
137
- if (!allMetricStores || allMetricStores.size === 0) {
134
+ current = current.__parent;
135
+ }
136
+ if (allMetricStores.size === 0) {
138
137
  return;
139
138
  }
140
139
  // Apply the action to each matching metric store
@@ -202,7 +202,9 @@ export class QuerysetStore {
202
202
  typeof this.getRootStore === "function" &&
203
203
  !this.isTemp) {
204
204
  const { isRoot, rootStore } = this.getRootStore(this.queryset);
205
- if (!isRoot && rootStore && (rootStore.lastSync || 0) >= (this.lastSync || 0)) {
205
+ // Only render from root if the root has been synced at least once
206
+ // This prevents child stores from getting empty data on first render
207
+ if (!isRoot && rootStore && rootStore.lastSync !== null) {
206
208
  pks = this.renderFromRoot(optimistic, rootStore);
207
209
  }
208
210
  }
@@ -247,6 +249,7 @@ export class QuerysetStore {
247
249
  let pk = instance[pkField];
248
250
  switch (operation.type) {
249
251
  case Type.CREATE:
252
+ case Type.BULK_CREATE:
250
253
  currentPks.add(pk);
251
254
  break;
252
255
  case Type.CHECKPOINT:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.1.93",
3
+ "version": "0.1.96",
4
4
  "type": "module",
5
5
  "module": "ESNext",
6
6
  "description": "The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate",