Skip to content

Commit

Permalink
fix(store): pass through record id as it is
Browse files Browse the repository at this point in the history
  • Loading branch information
smalluban committed Apr 12, 2024
1 parent 851c200 commit a5d9165
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 101 deletions.
97 changes: 39 additions & 58 deletions docs/store/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,41 @@

The model definition is based on a plain object with a JSON-like structure of default values for the instances. Each definition creates its own global space for the data. The store identifies the space by reference to the definition, so no register step is required, which should be done programmatically. You just define the model structure and start using it with the store.

The model can represent a singleton with only one instance, or it can have multiple instances with unique identifiers. Each instance of the model is immutable, so updating its state always produces a new version of the model. However, models can reference each other. The instance itself does not have to be updated if its related model changes - the immutable is only the bound, not the value.
The model can represent a singleton with only one instance, or it can have multiple instances with unique identifiers. Each instance of the model is immutable, so updating its state always produces a new version of the model. Also, models can reference each other. The instance itself does not have to be updated if its related model changes - the immutable is only the bound, not the value.

## Type
## Identifier

The store supports three types of models:
The store supports two types of model definitions:

* Singleton with only one instance
* Enumerables with multiple instances
* Listing enumerable models

Each type creates its own space for the cache in the memory.
Additionally, enumerable models can be listed rather than referenced by the identifier.

### Singleton & Enumerables
### Singleton

```javascript
const Model = {
id?: true,
value: "",
...
}
};
```

The `id` property indicates if the model has multiple instances, or it is a singleton. The only valid value for the `id` field is `true`. Otherwise, it should not be defined. For example, you may need only one instance of the `Profile` model of the currently logged-in user, but it can reference an enumerable `User` model.
The singleton model definition does not have the `id` property. It means that there is only one instance of the model in the store. The store always returns the same instance for the definition.

### Enumerables

```javascript
const User = {
const Model = {
id: true,
firstName: "",
lastName: "",
...,
};

const Profile = {
user: User,
isAdmin: false,
...,
};
...
}
```

The value for the identifier can be a `string`, or an `object` record (a map of primitive values, which is normalized and serialized to `string` representation). The object record allows identifying models by multiple parameters rather than on string id. For example, `SearchResult` model definition can be identified by `{ query: "", order: "asc", ... }` map of values.
For enumerable models, the `id` property must be set to `true`. It means that the model can have multiple instances with unique identifiers. The store returns an instance for each identifier. The identifier can be a `string`, or an `object` record (a map of primitive values, which is normalized and serialized to `string` representation).

The object record allows identifying models by multiple parameters rather than by string id. For example, `SearchResult` model definition can be identified by `{ query: "", order: "asc", ... }` map of values.


```javascript
const result = store.get(SearchResult, { query: "some", order: "desc" });
Expand All @@ -63,15 +59,19 @@ store.set(Model, { value: "test" }).then(model => {

### Listing Enumerables

The model definition must be a plain object by the design. However, the store supports a special type, which returns an array with a list of model instances. This type is represented by the enumerable model wrapped in the array - `[Model]`. It creates its own space for the data, and it can have multiple instances identified by a `string` or an `object` record just like other model definitions. For memory-based models, it always returns all instances of the definition, but it can be also used for models with external storage where the result depends on the `list` action (read more in [Storage](./storage.md) section).

The wrapped definition creates the reference, so the listing does not have to be defined before usage. It will always reference the same definition:
The store supports listing enumerable models by wrapping its definition with the array instance. The listing always reference the same base model definition:

```javascript
store.get([Model]) === store.get([Model])
```

The listing type fits best for the models, which can be represented as an array (like memory-based models):
The listing creates its own space for the result arrays, and it can have multiple instances identified by a `string` or an `object` record just like other model definitions:

```js
const filteredModels = store.get([Model], { query: 'some' });
```

For memory-based models, it always returns all instances of the definition, but it can be also used for models with external storage where the result depends on the `list` action (read more in [Storage](./storage.md) section).

```javascript
import { define, store, html } from 'hybrids';
Expand All @@ -84,10 +84,10 @@ const Todo = {

define({
tag: "my-element",
todoList: store([Todo]),
render: ({ todoList }) => html`
todos: store([Todo]),
render: ({ todos }) => html`
<ul>
${store.ready(todoList) && todoList.map(todo => html`
${store.ready(todos) && todos.map(todo => html`
<li>
<input type="checkbox" checked="${todo.checked}" />
<span>${todo.desc}</span>
Expand All @@ -98,26 +98,7 @@ define({
});
```

The listing type respects `cache` and `loose` options of the storage. By default the `loose` option is turn off for external storages (read more in [Storage](./storage.md) section).

#### Metadata

If the listing requires additional metadata (like pagination, offset, etc.), you should create a separate model definition with a nested array of required models. You should likely set the `loose` option for the relation to the base model, as a change of the model instance might invalidate the order of appearance in the result list.

```javascript
const TodoList = {
items: [Todo, { loose: true }],
offset: 0,
limit: 0,
[store.connect]: { ... },
};
```

## Structure

The model definition allows a subset of the JSON standard with minor changes. The model instance serializes to a form, which can be sent over the network without additional modification.

### Primitive Value
## Primitive Value

```javascript
const Model = {
Expand All @@ -130,7 +111,7 @@ const Model = {

The model definition supports primitive values of `string`, `number`, or `boolean` type. The default value defines the type of the property (it works similarly to the [transform feature](./property.md#transform) of the property factory). For example, for strings, it is the `String(value)`.

#### Validation
### Validation

The store provides client-side validation for supported primitive values, which is called within `store.set()` action. If it fails, the instance won't be updated, and an error will be attached to the instance. The rejected `Error` instance contains the `err.errors` object, where all of the validation errors are listed by the property names (you can read more about how to use it in the [Usage](./usage.md#draft-mode) section).

Expand All @@ -156,7 +137,7 @@ store.value(defaultValue: string | number | boolean, validate?: fn | RegExp, err
* **returns**:
* a `String`, `Number` or `Boolean` instance

### Computed Value
## Computed Value

```javascript
const Model = {
Expand All @@ -171,13 +152,13 @@ console.log(model.fullName); // logs "Great name!"

The computed property is based on other properties from the model. Its value is only calculated when the property is accessed for the first time (the result value is permanently cached). The property is non-enumerable to prevent serializing its value to the storage (for example, `JSON.stringify()` won't use it).

### Nested Object
## Nested Object

The model definition supports two types of nested objects. They might be internal, where the value is stored inside the model instance, or they can be external as model instances bound by the id (with separate memory space).

The nested object structure is similar to the external model definition. It can be used as a primary model definition as well. The store must have a way to distinguish if the definition's intention is an internal structure or an external model definition. You can find how the store chooses the right option below.

#### Object Instance (Internal)
### Object Instance (Internal)

```javascript
const Model = {
Expand All @@ -191,7 +172,7 @@ const Model = {

If the nested structure does not provide `id` property, and it is not connected to the storage (by the `[store.connect]`), the store assumes that this is the internal part of the parent model definition. As a result, the data will be attached to the model, and it is not shared with other instances. Each model instance will have its nested values. All the rules of the model definition apply, so nested objects can have their own deep nested structures, etc.

#### Model (External)
### Model (External)

```javascript
const ModelWithId = {
Expand All @@ -218,11 +199,11 @@ The parent model storage can provide a model instance data or a valid identifier

To remove the relation, set the property to `null` or `undefined` (it only removes the relation, not the related model). In that case, the value of the nested external object will be set to `undefined`.

### Nested Array
## Nested Array

The store supports nested arrays in a similar way to the nested objects described above. The first item of the array represents the type of structure - internal (primitives or object structures), or external reference to enumerable model definitions (by the `id` property). Updating the nested array must provide a new version of the array. You cannot change a single item from the array in place.

#### Primitives or Nested Objects (Internal)
### Primitives or Nested Objects (Internal)

```javascript
const Model = {
Expand All @@ -249,7 +230,7 @@ const Model = {
store.get(Model); // { domains: [], numbers: [] }
```

#### Models (External)
### Models (External)

```javascript
import OtherModel from './otherModel.js';
Expand All @@ -263,7 +244,7 @@ If the first item of the array is an enumerable model definition, the property r

The parent model's storage may provide a list of data for model instances or a list of identifiers. The update process and binding between models work the same as for a single external nested object.

### Cache Invalidation
## Cache Invalidation

By default, the store does not invalidate the cached value of the model instance when nested in the array external model changes. Because of the nature of the binding between models, when the nested model updates its state, the change will be reflected without a need to update the parent model's state.

Expand All @@ -288,7 +269,7 @@ store.set(pageOne.users[0], { name: 'New name' });

To prevent an endless loop of fetching data, the cached value of the parent model instance only invalidates if the `store.set` method is used. Updating the state of the nested model definition by fetching new values with the `store.get` action won't invalidate the parent model. Get action still respects the `cache` option of the parent storage. If you need a high rate of accuracy of the external data, you should set a very low value of the `cache` option in the storage, or even set it to `false`.

### Self Reference & Import Cycles
## Self Reference & Import Cycles

The model definition is based on the plain object definition, so by the JavaScript constraints, it is not possible to create a property, which is a model definition of itself, or use the definition from another ES module, which depends on the source file (there is an import cycle). In those situations, use the `store.ref(fn)` method, which sets a property to the result of the passed function in time of the definition (when the model definition is used for the first time).

Expand Down
78 changes: 40 additions & 38 deletions src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ function setupOfflineKey(config, threshold) {
return key;
}

const JSON_LIKE_REGEX = /^\{.+\}$/;
function setupStorage(config, options) {
if (typeof options === "function") options = { get: options };

Expand All @@ -151,13 +150,6 @@ function setupStorage(config, options) {
result.observe = (model, lastModel) => {
try {
let id = lastModel ? lastModel.id : model.id;
if (JSON_LIKE_REGEX.test(id)) {
try {
id = JSON.parse(id);
// istanbul ignore next
} catch (e) {} // eslint-disable-line no-empty
}

fn(id, model, lastModel);
} catch (e) {
console.error(e);
Expand Down Expand Up @@ -400,7 +392,7 @@ function resolveKey(Model, key, config) {
}

function stringifyModel(Model, msg) {
return `${msg}\n\nModel = ${JSON.stringify(Model, null, 2)}\n`;
return `${msg}\n\nModel definition:\n\n${JSON.stringify(Model, null, 2)}\n`;
}

const resolvedPromise = Promise.resolve();
Expand Down Expand Up @@ -485,9 +477,12 @@ function setupModel(Model, nested) {
Object.defineProperty(placeholder, key, {
get() {
throw Error(
`Model instance in ${
getModelState(this).state
} state - use store.pending(), store.error(), or store.ready() guards`,
stringifyModel(
Model,
`Model instance in ${
getModelState(this).state
} state - use store.pending(), store.error(), or store.ready() guards`,
),
);
},
enumerable: true,
Expand All @@ -497,13 +492,16 @@ function setupModel(Model, nested) {
if (key === "id") {
if (Model[key] !== true) {
throw TypeError(
"The 'id' property in the model definition must be set to 'true' or not be defined",
stringifyModel(
Model,
"The 'id' property in the model definition must be set to 'true' or not be defined",
),
);
}
return (model, data, lastModel) => {
let id;
if (hasOwnProperty.call(data, "id")) {
id = stringifyId(data.id);
id = normalizeId(data.id);
} else if (lastModel) {
id = lastModel.id;
} else {
Expand Down Expand Up @@ -745,9 +743,12 @@ const listPlaceholderPrototype = Object.getOwnPropertyNames(
Object.defineProperty(acc, key, {
get() {
throw Error(
`Model list instance in ${
getModelState(this).state
} state - use store.pending(), store.error(), or store.ready() guards`,
stringifyModel(
get(definitions.get(this).model),
`Model list instance in ${
getModelState(this).state
} state - use store.pending(), store.error(), or store.ready() guards`,
),
);
},
});
Expand Down Expand Up @@ -844,7 +845,7 @@ function setupListModel(Model, nested) {
: undefined,
);
if (modelConfig.enumerable) {
id = model.id;
id = stringifyId(model.id);
syncCache(modelConfig, id, model, invalidate);
}
}
Expand Down Expand Up @@ -930,27 +931,26 @@ function resolveTimestamp(h, v) {
return v || getCurrentTimestamp();
}

function stringifyId(id) {
switch (typeof id) {
case "object": {
const result = {};
function normalizeId(id) {
if (typeof id !== "object") return id !== undefined ? String(id) : id;

for (const key of Object.keys(id).sort()) {
if (typeof id[key] === "object" && id[key] !== null) {
throw TypeError(
`You must use primitive value for '${key}' key: ${typeof id[key]}`,
);
}
result[key] = id[key];
}
const result = {};

return JSON.stringify(result);
for (const key of Object.keys(id).sort()) {
if (typeof id[key] === "object" && id[key] !== null) {
throw TypeError(
`You must use primitive value for '${key}' key: ${typeof id[key]}`,
);
}
case "undefined":
return undefined;
default:
return String(id);
result[key] = id[key];
}

return result;
}

function stringifyId(id) {
id = normalizeId(id);
return typeof id === "object" ? JSON.stringify(id) : id;
}

const notFoundErrors = new WeakSet();
Expand Down Expand Up @@ -1008,6 +1008,8 @@ function get(Model, id) {
entry.resolved = false;
}

id = normalizeId(id);

return cache.get(config, stringId, (h, cachedModel) => {
if (cachedModel && pending(cachedModel)) return cachedModel;

Expand All @@ -1030,7 +1032,7 @@ function get(Model, id) {
const fallback = () =>
cachedModel ||
(offline && config.create(offline.get(stringId))) ||
config.placeholder(stringId);
config.placeholder(id);

try {
let result = config.storage.get(id);
Expand All @@ -1057,7 +1059,7 @@ function get(Model, id) {
throw notFoundError(Model, stringId);
}

if (data.id !== stringId) data.id = stringId;
if (data.id !== id) data.id = id;
const model = config.create(data);

if (offline) offline.set(stringId, model);
Expand All @@ -1069,7 +1071,7 @@ function get(Model, id) {
return setModelState(fallback(), "pending", result);
}

if (result.id !== stringId) result.id = stringId;
if (result.id !== id) result.id = id;
const model = config.create(result);

if (offline) {
Expand Down
5 changes: 2 additions & 3 deletions test/spec/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -2076,11 +2076,10 @@ describe("store:", () => {
it("calls storage get action with object id", () => {
const model = store.get(Model, { b: "c", a: "b" });

expect(model.id).toEqual(JSON.stringify({ a: "b", b: "c" }));
expect(model.id).toEqual({ a: "b", b: "c" });

return store.resolve(model).then((resultModel) => {
expect(resultModel.id).toEqual(JSON.stringify({ a: "b", b: "c" }));
expect(String(resultModel)).toBe(JSON.stringify({ a: "b", b: "c" }));
expect(resultModel.id).toEqual({ a: "b", b: "c" });
});
});

Expand Down
Loading

0 comments on commit a5d9165

Please sign in to comment.